From 765fff490f2577f4bb109a8fe48c1590a9ad4082 Mon Sep 17 00:00:00 2001
From: James Rosewell 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..5ac1bfb 100644 --- a/src/main/java/com/swancommunity/owid/Owid.java +++ b/src/main/java/com/swancommunity/owid/Owid.java @@ -20,7 +20,6 @@ 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 +29,22 @@ * *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 #tryParse(String)} or {@link #tryParseBytes(byte[])} 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 +68,74 @@ 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; - - /** - * 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. - */ - 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]; - } + private final Version version; + private final String domain; + private final Instant date; + private final byte[] payload; + private final byte[] signature; /** - * Creates a new unsigned OWID with the domain, date, and payload provided - * and the current version. + * Builds an instance from fields a reader or the creator has already + * settled. * - * @param domain the domain associated with the creator - * @param date the creation date, used to the minute - * @param payload the payload bytes + *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(String domain, Instant date, byte[] payload) { - this.version = Version.current(); + Owid(Version version, String domain, Instant date, byte[] payload, + byte[] signature) { + this.version = version; this.domain = domain; this.date = date; - this.payload = payload.clone(); - this.signature = new byte[0]; + this.payload = payload; + this.signature = signature; } /** - * 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 its base 64 form. + * + *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 #verifyDetailed(Crypto, List)}.
* - * @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 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 static Owid fromBase64(String value) throws OwidException { - return fromByteArray(decodeBase64(value)); + public static OwidParseResult tryParse(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); } /** - * Creates an OWID from its binary form. + * Reads a complete OWID from a buffer holding exactly one. * - * @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 + *The buffer must be one whole OWID and nothing else. Bytes after the + * envelope are refused, because this library has no framed reader and so + * there is nothing else they could belong to.
+ * + * @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 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; - } - owid.domain = reader.readString(); - owid.date = reader.readDate(version); - owid.payload = reader.readByteArray(); - owid.signature = reader.readSignature(); - return owid; + public static OwidParseResult tryParseBytes(byte[] buffer) { + return OwidReader.read(buffer); } /** @@ -163,7 +164,7 @@ 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); } @@ -181,7 +182,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 +197,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 verifyDetailed(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 version the version + * @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 verifyDetailedWithPublicKey(String publicPem, + ListReading a serialized OWID does not raise this. Data that arrived from + * outside is expected to be malformed sometimes, so + * {@link Owid#tryParse(String)} and {@link Owid#tryParseBytes(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..dd9174a --- /dev/null +++ b/src/main/java/com/swancommunity/owid/OwidParseResult.java @@ -0,0 +1,100 @@ +/* **************************************************************************** + * 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.
+ * + *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 OwidParseResult(Owid value, OwidParseStatus status) { + this.value = value; + this.status = status; + } + + /** The result of a read that produced the OWID given. */ + static OwidParseResult parsed(Owid value) { + return new OwidParseResult(value, OwidParseStatus.PARSED); + } + + /** The result of a read that failed for the reason given. */ + static OwidParseResult failed(OwidParseStatus status) { + return new OwidParseResult(null, status); + } + + /** + * 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#verifyDetailed(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 the read failed. Callers should + * test {@link #isSuccess()} first rather than testing this for null, + * because the status also says which of the expected problems it was. + * + * @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; + } + + /** + * 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..833baee --- /dev/null +++ b/src/main/java/com/swancommunity/owid/OwidParseStatus.java @@ -0,0 +1,96 @@ +/* **************************************************************************** + * 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. + */ + 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. + */ + 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. A Java byte array cannot hold + * more than {@link Integer#MAX_VALUE} bytes, so a declaration larger + * than that can never agree with the bytes present and this status is + * not reachable from the byte array surface. + */ + 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. + */ + MALFORMED_ENVELOPE +} 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..c36588a --- /dev/null +++ b/src/main/java/com/swancommunity/owid/OwidReader.java @@ -0,0 +1,294 @@ +/* **************************************************************************** + * 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.
+ * + *This is the exact buffer contract, meaning the envelope has to end + * where the buffer does. The library has no framed reader, so there is + * nothing that later bytes could belong to.
+ */ +final class OwidReader { + + private OwidReader() { + } + + /** Reads one complete OWID occupying the whole of the buffer. */ + static OwidParseResult read(byte[] buffer) { + if (buffer == null || buffer.length == 0) { + return OwidParseResult.failed(OwidParseStatus.MISSING_INPUT); + } + + int total = buffer.length; + Version version = Version.forByte(buffer[0] & 0xFF); + if (version == null) { + return OwidParseResult.failed( + OwidParseStatus.UNSUPPORTED_VERSION); + } + int at = 1; + + if (version == Version.EMPTY) { + // The marker for an absent optional OWID is the version byte and + // nothing else, so anything after it belongs to no field. + if (at != total) { + return OwidParseResult.failed( + OwidParseStatus.MALFORMED_ENVELOPE); + } + return OwidParseResult.parsed(new Owid( + version, "", Io.baseDate(), new byte[0], new byte[0])); + } + + // 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. + // + // 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. + long present = (long) (total - at) - Owid.SIGNATURE_LENGTH; + 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 the count above can never agree with + // 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 (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. + return OwidParseResult.failed( + OwidParseStatus.MALFORMED_ENVELOPE); + } + + return OwidParseResult.parsed( + new Owid(version, domain, date, payload, signature)); + } + + /** + * 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..f027025 --- /dev/null +++ b/src/main/java/com/swancommunity/owid/OwidSignatureStatus.java @@ -0,0 +1,82 @@ +/* **************************************************************************** + * 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. + */ + 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. Reaching it + * needs an OWID and its chain to approach the two gigabyte limit of a + * Java array. + */ + 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..aa5e01c 100644 --- a/src/main/java/com/swancommunity/owid/Version.java +++ b/src/main/java/com/swancommunity/owid/Version.java @@ -70,19 +70,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..c17af3b --- /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.tryParse(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..17a7a3b 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()); ListEach 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"); + 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"); + } +} 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..db1fd27 --- /dev/null +++ b/src/test/java/com/swancommunity/owid/ParseContractTest.java @@ -0,0 +1,305 @@ +/* **************************************************************************** + * 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.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.
+ */ +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.tryParseBytes(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.tryParseBytes(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.tryParse(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.tryParse(null), OwidParseStatus.MISSING_INPUT); + ParseAssert.failed(Owid.tryParse(""), OwidParseStatus.MISSING_INPUT); + ParseAssert.failed(Owid.tryParseBytes(null), + OwidParseStatus.MISSING_INPUT); + ParseAssert.failed(Owid.tryParseBytes(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.tryParse(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.tryParse(padded)); + Owid fromUnpadded = ParseAssert.parsed(Owid.tryParse(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.tryParseBytes(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.tryParseBytes(Arrays.copyOf(complete, domainEnd - 2)), + OwidParseStatus.UNEXPECTED_END); + + // Inside the date, two of its four bytes present. + ParseAssert.failed( + Owid.tryParseBytes(Arrays.copyOf(complete, domainEnd + 2)), + OwidParseStatus.UNEXPECTED_END); + + // Inside the payload length field, two of its four bytes present. + ParseAssert.failed( + Owid.tryParseBytes(Arrays.copyOf(complete, domainEnd + 6)), + OwidParseStatus.UNEXPECTED_END); + } + + /** + * The marker for an absent optional OWID is the single version byte, so + * it reads, and anything after it belongs to no field. + */ + @Test + void emptyMarkerParsesAndTrailingBytesDoNot() { + Owid owid = ParseAssert.parsed( + Owid.tryParseBytes(Owid.emptyByteArray())); + assertEquals(Version.EMPTY, owid.getVersion(), + "should read the empty marker"); + + ParseAssert.failed(Owid.tryParseBytes(new byte[] {0, 1}), + OwidParseStatus.MALFORMED_ENVELOPE); + } + + /** + * 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.tryParseBytes(bytes)); + + OwidVerificationResult verification = + owid.verifyDetailed(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.
+ */ +class SignatureStatusTest { + + private static final ListAn 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 #tryParse(String)} or {@link #tryParseBytes(byte[])} reading bytes - * that were already a complete OWID, or {@link Creator#createBytes(byte[])} + * {@link #parse(String)} or {@link #parse(byte[])} 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 @@ -106,13 +106,13 @@ public final class Owid { * *
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 #verifyDetailed(Crypto, List)}.
+ * 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 static OwidParseResult tryParse(String value) { + public static OwidParseResult parse(String value) { if (value == null || value.isEmpty()) { return OwidParseResult.failed(OwidParseStatus.MISSING_INPUT); } @@ -134,7 +134,7 @@ public static OwidParseResult tryParse(String value) { * @return the OWID and {@link OwidParseStatus#PARSED}, or no value and * the reason the bytes are not an OWID */ - public static OwidParseResult tryParseBytes(byte[] buffer) { + public static OwidParseResult parse(byte[] buffer) { return OwidReader.read(buffer); } @@ -169,8 +169,14 @@ void toBuffer(ByteArrayOutputStream buffer) throws OwidException { } /** - * 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 through {@link #parse(byte[])} reports + * {@link OwidParseStatus#UNSUPPORTED_VERSION}, because the marker stands + * for the absence of an identifier rather than for one, and a whole + * buffer holding nothing but the marker holds no OWID. Only a framed + * reader, which this library does not have, can make sense of it.
* * @return a single byte array holding the empty marker */ @@ -387,8 +393,7 @@ public boolean verifyWithPublicKey(String publicPem, ListKey material that cannot be decoded reports
* {@link OwidSignatureStatus#INVALID_KEY}, because the fault is in the
@@ -433,7 +438,7 @@ public OwidVerificationResult verifyDetailed(Crypto crypto,
* one, in the same order as when signed
* @return the outcome of the check
*/
- public OwidVerificationResult verifyDetailedWithPublicKey(String publicPem,
+ public OwidVerificationResult verify(String publicPem,
List Reading a serialized OWID does not raise this. Data that arrived from
* outside is expected to be malformed sometimes, so
- * {@link Owid#tryParse(String)} and {@link Owid#tryParseBytes(byte[])} report
+ * {@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. Not reachable in Java today, and so not covered by a test. Every
+ * failure this reader can meet is classified by one of the members above,
+ * and the one place that still reports this is the check that the
+ * envelope ended where the buffer did, which cannot fire 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 this status appear, so
+ * the backstop does work. 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. 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. No OWID carries this version. A whole buffer holding nothing but the
+ * marker holds no identifier, so {@link Owid#parse(byte[])} refuses it as
+ * {@link OwidParseStatus#UNSUPPORTED_VERSION} rather than handing back
+ * something nothing has ever signed.
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}. Two members are not, and + * cannot be, being {@link OwidParseStatus#INVALID_INPUT_TYPE}, which the + * compiler already refuses, and + * {@link OwidParseStatus#IMPLEMENTATION_CAPACITY_EXCEEDED}, which a Java byte + * array cannot reach. A third, + * {@link OwidParseStatus#MALFORMED_ENVELOPE}, is a backstop with no path to + * it while the byte count rule holds. The reason is recorded on each of those + * members as well.
*/ class ParseContractTest { @@ -51,7 +62,7 @@ private static byte[] wellFormed() { */ @Test void successReportsAllThreeFacts() { - OwidParseResult result = Owid.tryParseBytes(wellFormed()); + OwidParseResult result = Owid.parse(wellFormed()); assertTrue(result.isSuccess(), "should report success"); assertNotNull(result.getValue(), "should hand back the OWID"); @@ -65,7 +76,7 @@ void successReportsAllThreeFacts() { @Test void emptyPayloadParses() { Owid owid = ParseAssert.parsed( - Owid.tryParseBytes(Envelope.version3(new byte[0]))); + Owid.parse(Envelope.version3(new byte[0]))); assertEquals(0, owid.getPayloadLength(), "should read an empty payload"); @@ -82,7 +93,7 @@ void oneMebibytePayloadParsesFromBase64() { String encoded = Base64.getEncoder().encodeToString( Envelope.version3(payload)); - Owid owid = ParseAssert.parsed(Owid.tryParse(encoded)); + Owid owid = ParseAssert.parsed(Owid.parse(encoded)); assertEquals(payload.length, owid.getPayloadLength(), "should read the whole payload"); @@ -93,11 +104,12 @@ void oneMebibytePayloadParsesFromBase64() { /** Nothing to read is its own answer, on both surfaces. */ @Test void absentInputIsMissingInput() { - ParseAssert.failed(Owid.tryParse(null), OwidParseStatus.MISSING_INPUT); - ParseAssert.failed(Owid.tryParse(""), OwidParseStatus.MISSING_INPUT); - ParseAssert.failed(Owid.tryParseBytes(null), + 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.tryParseBytes(new byte[0]), + ParseAssert.failed(Owid.parse(new byte[0]), OwidParseStatus.MISSING_INPUT); } @@ -117,7 +129,7 @@ void invalidBase64IsReported() { }; for (String value : values) { OwidParseResult result = assertDoesNotThrow( - () -> Owid.tryParse(value), + () -> Owid.parse(value), "should not throw for a value that is not base 64"); ParseAssert.failed(result, OwidParseStatus.INVALID_BASE64); } @@ -132,8 +144,8 @@ void unpaddedBase64IsAccepted() { String padded = Base64.getEncoder().encodeToString(wellFormed()); String unpadded = padded.replace("=", ""); - Owid fromPadded = ParseAssert.parsed(Owid.tryParse(padded)); - Owid fromUnpadded = ParseAssert.parsed(Owid.tryParse(unpadded)); + 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"); @@ -145,7 +157,7 @@ void unknownVersionIsReported() { byte[] bytes = wellFormed(); bytes[0] = 0x04; - ParseAssert.failed(Owid.tryParseBytes(bytes), + ParseAssert.failed(Owid.parse(bytes), OwidParseStatus.UNSUPPORTED_VERSION); } @@ -160,33 +172,77 @@ void truncatedFieldsAreUnexpectedEnd() { // Inside the domain, with no terminator reached. ParseAssert.failed( - Owid.tryParseBytes(Arrays.copyOf(complete, domainEnd - 2)), + Owid.parse(Arrays.copyOf(complete, domainEnd - 2)), OwidParseStatus.UNEXPECTED_END); // Inside the date, two of its four bytes present. ParseAssert.failed( - Owid.tryParseBytes(Arrays.copyOf(complete, domainEnd + 2)), + Owid.parse(Arrays.copyOf(complete, domainEnd + 2)), OwidParseStatus.UNEXPECTED_END); // Inside the payload length field, two of its four bytes present. ParseAssert.failed( - Owid.tryParseBytes(Arrays.copyOf(complete, domainEnd + 6)), + Owid.parse(Arrays.copyOf(complete, domainEnd + 6)), OwidParseStatus.UNEXPECTED_END); } /** - * The marker for an absent optional OWID is the single version byte, so - * it reads, and anything after it belongs to no field. + * 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 emptyMarkerParsesAndTrailingBytesDoNot() { - Owid owid = ParseAssert.parsed( - Owid.tryParseBytes(Owid.emptyByteArray())); - assertEquals(Version.EMPTY, owid.getVersion(), - "should read the empty marker"); + 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.tryParseBytes(new byte[] {0, 1}), - OwidParseStatus.MALFORMED_ENVELOPE); + ParseAssert.failed(Owid.parse(longer), + OwidParseStatus.BYTE_COUNT_MISMATCH); + } + + /** + * The marker for an absent optional OWID is refused by the whole buffer + * read. + * + *It stands for the absence of an identifier rather than for one, and + * 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. + * That is the state the construction boundary exists to prevent, and it + * could never verify. A framed reader, which this library does not have, + * would still read the marker as the absence it means.
+ */ + @Test + void emptyMarkerIsRefused() { + ParseAssert.failed(Owid.parse(Owid.emptyByteArray()), + OwidParseStatus.UNSUPPORTED_VERSION); + ParseAssert.failed(Owid.parse(new byte[] {0, 1}), + OwidParseStatus.UNSUPPORTED_VERSION); } /** @@ -205,10 +261,10 @@ void structurallyValidWithWrongSignatureParsesThenFailsVerification() // only the signature stops describing the contents. bytes[bytes.length - Owid.SIGNATURE_LENGTH - 1] ^= 0x01; - Owid owid = ParseAssert.parsed(Owid.tryParseBytes(bytes)); + Owid owid = ParseAssert.parsed(Owid.parse(bytes)); OwidVerificationResult verification = - owid.verifyDetailed(crypto, Collections.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 { @@ -48,7 +54,7 @@ void genuineSignatureIsValid() throws OwidException { Owid owid = Creator.create("example.com", crypto) .createString("payload"); - OwidVerificationResult result = owid.verifyDetailed(crypto, NONE); + OwidVerificationResult result = owid.verify(crypto, NONE); assertTrue(result.isValid(), "a genuine signature should be valid"); assertEquals(OwidSignatureStatus.SIGNATURE_VALID, result.getStatus(), @@ -62,7 +68,7 @@ void genuineSignatureIsValidThroughPem() throws OwidException { Owid owid = Creator.create("example.com", crypto) .createString("payload"); - OwidVerificationResult result = owid.verifyDetailedWithPublicKey( + OwidVerificationResult result = owid.verify( crypto.publicKeyPem(), NONE); assertEquals(OwidSignatureStatus.SIGNATURE_VALID, result.getStatus(), @@ -79,7 +85,7 @@ void wrongKeyIsSignatureInvalid() throws OwidException { .createString("payload"); OwidVerificationResult result = - owid.verifyDetailed(crypto(), NONE); + owid.verify(crypto(), NONE); assertFalse(result.isValid(), "the signature should not be valid"); assertEquals(OwidSignatureStatus.SIGNATURE_INVALID, result.getStatus(), @@ -96,13 +102,13 @@ void noKeyIsKeyUnavailable() throws OwidException { .createString("payload"); assertEquals(OwidSignatureStatus.KEY_UNAVAILABLE, - owid.verifyDetailed(null, NONE).getStatus(), + owid.verify((Crypto) null, NONE).getStatus(), "a missing crypto instance should not judge the signature"); assertEquals(OwidSignatureStatus.KEY_UNAVAILABLE, - owid.verifyDetailedWithPublicKey(null, NONE).getStatus(), + owid.verify((String) null, NONE).getStatus(), "a missing PEM should not judge the signature"); assertEquals(OwidSignatureStatus.KEY_UNAVAILABLE, - owid.verifyDetailedWithPublicKey(" ", NONE).getStatus(), + owid.verify(" ", NONE).getStatus(), "an empty PEM should not judge the signature"); } @@ -119,11 +125,11 @@ void undecodableKeyIsInvalidKey() throws OwidException { .createString("payload"); assertEquals(OwidSignatureStatus.INVALID_KEY, - owid.verifyDetailedWithPublicKey("not a PEM", NONE) + owid.verify("not a PEM", NONE) .getStatus(), "material that is not a key should be reported as the key"); assertEquals(OwidSignatureStatus.INVALID_KEY, - owid.verifyDetailedWithPublicKey( + owid.verify( "-----BEGIN PUBLIC KEY-----\nAAAA\n" + "-----END PUBLIC KEY-----\n", NONE) .getStatus(), @@ -132,17 +138,30 @@ void undecodableKeyIsInvalidKey() throws OwidException { /** * A signature field that is not the length the version requires cannot be - * checked. The marker for an absent optional OWID is the one thing that - * reaches a verification surface with no signature at all. + * checked, and saying so is not the same as saying the signature is + * wrong. + * + *The 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 missingSignatureIsInvalidSignatureLength() throws OwidException { - Owid marker = ParseAssert.parsed( - Owid.tryParseBytes(Owid.emptyByteArray())); + 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, - marker.verifyDetailed(crypto(), NONE).getStatus(), + 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"); } /** @@ -162,7 +181,7 @@ void unencodableFieldIsVerificationError() throws OwidException { Envelope.filled(Owid.SIGNATURE_LENGTH, (byte) 1)); assertEquals(OwidSignatureStatus.VERIFICATION_ERROR, - owid.verifyDetailed(crypto(), NONE).getStatus(), + owid.verify(crypto(), NONE).getStatus(), "a field that cannot be encoded is not an invalid signature"); } diff --git a/src/test/java/com/swancommunity/owid/WireVectorsTest.java b/src/test/java/com/swancommunity/owid/WireVectorsTest.java index 719008c..917afd8 100644 --- a/src/test/java/com/swancommunity/owid/WireVectorsTest.java +++ b/src/test/java/com/swancommunity/owid/WireVectorsTest.java @@ -61,7 +61,7 @@ private static byte[] decode(String value) { @Test void vectorsReadFromUnpaddedBase64() { for (String vector : new String[] {CREATOR, SUPPLIER, BAD}) { - Owid owid = ParseAssert.parsed(Owid.tryParse(vector)); + Owid owid = ParseAssert.parsed(Owid.parse(vector)); assertArrayEquals(decode(vector), assertDoesNotThrow(owid::asByteArray), "should read the same bytes from the encoded form"); @@ -71,7 +71,7 @@ void vectorsReadFromUnpaddedBase64() { @Test void creatorRoundTripsByteExact() throws OwidException { byte[] original = decode(CREATOR); - Owid owid = ParseAssert.parsed(Owid.tryParseBytes(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"); @@ -84,7 +84,7 @@ void creatorRoundTripsByteExact() throws OwidException { @Test void supplierRoundTripsByteExact() throws OwidException { byte[] original = decode(SUPPLIER); - Owid owid = ParseAssert.parsed(Owid.tryParseBytes(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(), @@ -100,7 +100,7 @@ void supplierRoundTripsByteExact() throws OwidException { @Test void badParsesAndRoundTrips() throws OwidException { byte[] original = decode(BAD); - Owid owid = ParseAssert.parsed(Owid.tryParseBytes(original)); + Owid owid = ParseAssert.parsed(Owid.parse(original)); assertEquals("badssp.swan-demo.uk", owid.getDomain(), "should read the domain"); assertArrayEquals(original, owid.asByteArray(), From 4aa28c9516028b8daa37cd89533cf06fa4c2f929 Mon Sep 17 00:00:00 2001 From: James RosewellAn 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)} or {@link #parse(byte[])} reading bytes that were - * already a complete OWID, or {@link Creator#createBytes(byte[])} + * {@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 @@ -120,22 +123,101 @@ public static OwidParseResult parse(String value) { if (buffer == null) { return OwidParseResult.failed(OwidParseStatus.INVALID_BASE64); } - return OwidReader.read(buffer); + return OwidReader.read(buffer, 0, buffer.length, false); } /** * Reads a complete OWID from a buffer holding exactly one. * - *
The buffer must be one whole OWID and nothing else. Bytes after the - * envelope are refused, because this library has no framed reader and so - * there is nothing else they could belong to.
+ *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 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 OwidParseResult parse(byte[] buffer) { - return OwidReader.read(buffer); + if (buffer == null) { + return OwidParseResult.failed(OwidParseStatus.MISSING_INPUT); + } + return OwidReader.read(buffer, 0, buffer.length, false); + } + + /** + * 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 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); + } + if (result.isSuccess()) { + // Only a successful read moves the buffer on. A failed read + // reports consuming nothing, so the arithmetic alone would leave + // the buffer where it was, and this says so outright rather than + // resting on that. + // + // 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; } /** @@ -172,11 +254,13 @@ void toBuffer(ByteArrayOutputStream buffer) throws OwidException { * Writes the marker for an absent optional OWID, being the single byte * zero, for embedding in a larger framed byte array. * - *Reading it back through {@link #parse(byte[])} reports + *
Every reading surface here, framed included, refuses it as * {@link OwidParseStatus#UNSUPPORTED_VERSION}, because the marker stands - * for the absence of an identifier rather than for one, and a whole - * buffer holding nothing but the marker holds no OWID. Only a framed - * reader, which this library does not have, can make sense of it.
+ * for the absence of an identifier rather than for one, and handing back + * an OWID with no domain, no date and no signature would put one in a + * caller's hands that nothing had ever signed. A caller walking a stream + * that carries markers therefore has to skip them itself, there being no + * status in the shared vocabulary that means an absent node. * * @return a single byte array holding the empty marker */ diff --git a/src/main/java/com/swancommunity/owid/OwidParseResult.java b/src/main/java/com/swancommunity/owid/OwidParseResult.java index 9cd24f6..48705d1 100644 --- a/src/main/java/com/swancommunity/owid/OwidParseResult.java +++ b/src/main/java/com/swancommunity/owid/OwidParseResult.java @@ -39,19 +39,26 @@ public final class OwidParseResult { private final OwidParseStatus status; - private OwidParseResult(Owid value, 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. */ - static OwidParseResult parsed(Owid value) { - return new OwidParseResult(value, OwidParseStatus.PARSED); + /** + * 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); + return new OwidParseResult(null, status, 0); } /** @@ -87,6 +94,23 @@ public OwidParseStatus getStatus() { return status; } + /** + * How many bytes the envelope occupied, or zero when the read failed. + * + *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. Reading a whole buffer this is the length of the buffer, + * because there the envelope is the whole of it.
+ * + *Zero on failure, since a read that failed consumed nothing.
+ * + * @return the length of the envelope in bytes, 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. diff --git a/src/main/java/com/swancommunity/owid/OwidParseStatus.java b/src/main/java/com/swancommunity/owid/OwidParseStatus.java index 19e7f31..8e0e0cc 100644 --- a/src/main/java/com/swancommunity/owid/OwidParseStatus.java +++ b/src/main/java/com/swancommunity/owid/OwidParseStatus.java @@ -60,6 +60,11 @@ public enum OwidParseStatus { * 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.
*/ UNEXPECTED_END, @@ -74,16 +79,26 @@ public enum OwidParseStatus { * 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}.
*/ 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. A Java byte array cannot hold - * more than {@link Integer#MAX_VALUE} bytes, so a declaration larger - * than that can never agree with the bytes present and this status is - * not reachable from the byte array surface. + * 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, @@ -92,16 +107,17 @@ public enum OwidParseStatus { * fallback for the genuinely unclassified, not a substitute for naming a * failure that is already understood. * - *Not reachable in Java today, and so not covered by a test. Every - * failure this reader can meet is classified by one of the members above, - * and the one place that still reports this is the check that the - * envelope ended where the buffer did, which cannot fire 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 this status appear, so - * the backstop does work.
+ *Not reachable in Java today, on either reading surface, and so not + * covered by a test. Every failure these readers can meet is classified + * by one of the members above. The one place that still reports this is + * the check that the 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 + * this status appear, so the backstop does work.
*/ MALFORMED_ENVELOPE } diff --git a/src/main/java/com/swancommunity/owid/OwidReader.java b/src/main/java/com/swancommunity/owid/OwidReader.java index d4cb153..c4ed0fe 100644 --- a/src/main/java/com/swancommunity/owid/OwidReader.java +++ b/src/main/java/com/swancommunity/owid/OwidReader.java @@ -35,26 +35,36 @@ * would still be built and unwound, so the cost would remain and only the * surface would look different. * - *This is the exact buffer contract, meaning the envelope has to end - * where the buffer does. The library has no framed reader, so there is - * nothing that later bytes could belong to.
+ *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 complete OWID occupying the whole of the buffer. */ - static OwidParseResult read(byte[] buffer) { + /** + * 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 buffer with no bytes in it is reported as the + // 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 || buffer.length == 0) { + if (buffer == null || total - from <= 0) { return OwidParseResult.failed(OwidParseStatus.MISSING_INPUT); } - int total = buffer.length; - Version version = Version.forByte(buffer[0] & 0xFF); + Version version = Version.forByte(buffer[from] & 0xFF); if (version == null || version == Version.EMPTY) { // The empty marker, being the single byte zero, is refused here // along with the versions this implementation does not know. It @@ -63,12 +73,14 @@ static OwidParseResult read(byte[] buffer) { // no signature, and handing one back would put an OWID in a // caller's hands that nothing had ever signed. That is the state // the construction boundary exists to prevent, and it could never - // verify. A framed reader, which this library does not have, - // would still read the marker as the absence it means. + // verify. The framed read refuses it for the same reason, so a + // caller walking a stream that carries markers has to skip them + // itself, there being no status in the shared vocabulary that + // means an absent node. return OwidParseResult.failed( OwidParseStatus.UNSUPPORTED_VERSION); } - int at = 1; + 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 @@ -135,14 +147,25 @@ static OwidParseResult read(byte[] buffer) { // rather than wrapping, and a negative count can never equal a // declaration. // - // 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 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 (present != declared) { + if (framed) { + if (present < declared) { + return OwidParseResult.failed( + OwidParseStatus.UNEXPECTED_END); + } + } else if (present != declared) { return OwidParseResult.failed( OwidParseStatus.BYTE_COUNT_MISMATCH); } @@ -150,10 +173,10 @@ static OwidParseResult read(byte[] buffer) { // 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 the count above can never agree with - // a larger declaration and this cannot fire today. It is kept so a - // future change to that arithmetic cannot silently truncate the cast - // below. + // 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); @@ -168,16 +191,18 @@ static OwidParseResult read(byte[] buffer) { System.arraycopy(buffer, at, signature, 0, Owid.SIGNATURE_LENGTH); at += Owid.SIGNATURE_LENGTH; - if (at != total) { + 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. + // 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)); + new Owid(version, domain, date, payload, signature), + at - from); } /** diff --git a/src/test/java/com/example/owidconsumer/ReadmeExampleTest.java b/src/test/java/com/example/owidconsumer/ReadmeExampleTest.java index 06b0555..06e72ad 100644 --- a/src/test/java/com/example/owidconsumer/ReadmeExampleTest.java +++ b/src/test/java/com/example/owidconsumer/ReadmeExampleTest.java @@ -25,7 +25,10 @@ import com.swancommunity.owid.Owid; import com.swancommunity.owid.OwidException; import com.swancommunity.owid.OwidParseResult; +import java.nio.ByteBuffer; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; import org.junit.jupiter.api.Test; /** @@ -76,6 +79,40 @@ void createSerializeReadBackAndVerify() throws OwidException { } } + /** + * The framed loop from the README, reading two OWIDs written one after + * the other into the same array. + */ + @Test + void readingOneOwidAfterAnother() throws OwidException { + Crypto crypto = Crypto.generate(); + Creator creator = Creator.create("example.com", crypto); + byte[] one = creator.createString("one").asByteArray(); + byte[] two = creator.createString("two").asByteArray(); + byte[] bytes = new byte[one.length + two.length]; + System.arraycopy(one, 0, bytes, 0, one.length); + System.arraycopy(two, 0, bytes, one.length, two.length); + + 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 is refused here too, because handing + // one back would be an OWID nothing had ever signed. + ParseAssert.failed(Owid.parse(ByteBuffer.wrap(Owid.emptyByteArray())), + OwidParseStatus.UNSUPPORTED_VERSION); + } + + /** + * 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/ParseAssert.java b/src/test/java/com/swancommunity/owid/ParseAssert.java index 858bfba..0db6408 100644 --- a/src/test/java/com/swancommunity/owid/ParseAssert.java +++ b/src/test/java/com/swancommunity/owid/ParseAssert.java @@ -46,6 +46,8 @@ static Owid parsed(OwidParseResult result) { "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(); } @@ -61,5 +63,10 @@ static void failed(OwidParseResult result, OwidParseStatus expected) { "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"); } } diff --git a/src/test/java/com/swancommunity/owid/ParseContractTest.java b/src/test/java/com/swancommunity/owid/ParseContractTest.java index f666091..110de81 100644 --- a/src/test/java/com/swancommunity/owid/ParseContractTest.java +++ b/src/test/java/com/swancommunity/owid/ParseContractTest.java @@ -24,6 +24,7 @@ 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; @@ -38,16 +39,21 @@ * {@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}. Two members are not, and * cannot be, being {@link OwidParseStatus#INVALID_INPUT_TYPE}, which the * compiler already refuses, and * {@link OwidParseStatus#IMPLEMENTATION_CAPACITY_EXCEEDED}, which a Java byte - * array cannot reach. A third, + * array cannot reach on either surface. A third, * {@link OwidParseStatus#MALFORMED_ENVELOPE}, is a backstop with no path to - * it while the byte count rule holds. The reason is recorded on each of those - * members as well.
+ * it while the byte count rule holds, and the framed surface does not apply + * that check at all. The reason is recorded on each of those members as + * well. */ class ParseContractTest { @@ -289,13 +295,14 @@ void readingTakesNoKeyAndNoCrypto() { checked++; for (Class> parameter : method.getParameterTypes()) { assertTrue(parameter == String.class - || parameter == byte[].class, + || parameter == byte[].class + || parameter == ByteBuffer.class, "reading should take only the data to read, but " + method.getName() + " takes " + parameter.getName()); } } - assertEquals(2, checked, "should have checked both read surfaces"); + assertEquals(3, checked, "should have checked every read surface"); } /** diff --git a/src/test/java/com/swancommunity/owid/PayloadLengthTest.java b/src/test/java/com/swancommunity/owid/PayloadLengthTest.java index a04d887..e6f9b70 100644 --- a/src/test/java/com/swancommunity/owid/PayloadLengthTest.java +++ b/src/test/java/com/swancommunity/owid/PayloadLengthTest.java @@ -23,6 +23,7 @@ import java.io.ByteArrayOutputStream; import java.lang.management.ManagementFactory; import java.lang.management.ThreadMXBean; +import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Collections; @@ -219,6 +220,19 @@ void mismatchedLargeDeclarationRefusedWithoutAllocating() { ParseAssert.failed(result, OwidParseStatus.BYTE_COUNT_MISMATCH); assertTrue(allocated < ALLOCATION_BOUND, "declared " + declared + " allocated " + allocated + " bytes"); + + // The framed read is handed the same claim, since it sizes the + // payload from the declaration too and a sender picks the number + // there as well. + ByteBuffer framed = ByteBuffer.wrap(bytes); + before = allocatedBytes(); + OwidParseResult framedResult = Owid.parse(framed); + allocated = allocatedBytes() - before; + ParseAssert.failed(framedResult, OwidParseStatus.UNEXPECTED_END); + assertTrue(allocated < ALLOCATION_BOUND, "framed declared " + + declared + " allocated " + allocated + " bytes"); + assertEquals(0, framed.position(), + "a refused frame should consume nothing"); } } From 4ce7192294ce94f14791f7ea75169c007da79d2f Mon Sep 17 00:00:00 2001 From: James RosewellEvery reading surface here, framed included, refuses it as - * {@link OwidParseStatus#UNSUPPORTED_VERSION}, because the marker stands - * for the absence of an identifier rather than for one, and handing back - * an OWID with no domain, no date and no signature would put one in a - * caller's hands that nothing had ever signed. A caller walking a stream - * that carries markers therefore has to skip them itself, there being no - * status in the shared vocabulary that means an absent node.
+ *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. 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. Read as a whole buffer the marker has to be the whole of it, + * and bytes after it are + * {@link OwidParseStatus#MALFORMED_ENVELOPE}.
* * @return a single byte array holding the empty marker */ diff --git a/src/main/java/com/swancommunity/owid/OwidParseResult.java b/src/main/java/com/swancommunity/owid/OwidParseResult.java index 48705d1..20c8525 100644 --- a/src/main/java/com/swancommunity/owid/OwidParseResult.java +++ b/src/main/java/com/swancommunity/owid/OwidParseResult.java @@ -27,7 +27,7 @@ *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.
+ * 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 @@ -61,6 +61,16 @@ 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 @@ -74,9 +84,10 @@ public boolean isSuccess() { } /** - * The OWID that was read, or null when the read failed. Callers should + * 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. + * 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 */ @@ -95,7 +106,7 @@ public OwidParseStatus getStatus() { } /** - * How many bytes the envelope occupied, or zero when the read failed. + * 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 @@ -103,9 +114,13 @@ public OwidParseStatus getStatus() { * not have to. Reading a whole buffer this is the length of the buffer, * because there the envelope is the whole of it.
* - *Zero on failure, since a read that failed consumed nothing.
+ *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 length of the envelope in bytes, or zero + * @return the bytes the read occupied, or zero */ public int getByteCount() { return byteCount; diff --git a/src/main/java/com/swancommunity/owid/OwidParseStatus.java b/src/main/java/com/swancommunity/owid/OwidParseStatus.java index 8e0e0cc..7dd0cf1 100644 --- a/src/main/java/com/swancommunity/owid/OwidParseStatus.java +++ b/src/main/java/com/swancommunity/owid/OwidParseStatus.java @@ -64,7 +64,10 @@ public enum OwidParseStatus { *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.
+ * 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, @@ -84,7 +87,9 @@ public enum OwidParseStatus { * 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}. + * {@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, @@ -107,17 +112,38 @@ public enum OwidParseStatus { * fallback for the genuinely unclassified, not a substitute for naming a * failure that is already understood. * - *Not reachable in Java today, on either reading surface, and so not - * covered by a test. Every failure these readers can meet is classified - * by one of the members above. The one place that still reports this is - * the check that the 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 - * this status appear, so the backstop does work.
+ *One thing reaches it, being the marker for an absent node followed + * by bytes on the whole buffer contract, where the marker has to be the + * whole of the buffer and what follows belongs to no field.
+ * + *The other 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.
+ * + *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. Reading a whole buffer the marker has to be the + * whole of it, and bytes after it are + * {@link #MALFORMED_ENVELOPE}.
*/ - MALFORMED_ENVELOPE + ABSENT_NODE } diff --git a/src/main/java/com/swancommunity/owid/OwidReader.java b/src/main/java/com/swancommunity/owid/OwidReader.java index c4ed0fe..06e161f 100644 --- a/src/main/java/com/swancommunity/owid/OwidReader.java +++ b/src/main/java/com/swancommunity/owid/OwidReader.java @@ -65,18 +65,29 @@ static OwidParseResult read(byte[] buffer, int from, int total, } Version version = Version.forByte(buffer[from] & 0xFF); - if (version == null || version == Version.EMPTY) { - // The empty marker, being the single byte zero, is refused here - // along with the versions this implementation does not know. It - // stands for an absent node inside a framed stream rather than - // for an identifier, so what it carries is no domain, no date and - // no signature, and handing one back would put an OWID in a - // caller's hands that nothing had ever signed. That is the state - // the construction boundary exists to prevent, and it could never - // verify. The framed read refuses it for the same reason, so a - // caller walking a stream that carries markers has to skip them - // itself, there being no status in the shared vocabulary that - // means an absent node. + 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. + // + // Reading a frame the marker is consumed, so a caller walking a + // run of frames steps over the absent node and reads the one + // after it. Reading a whole buffer the marker has to be the whole + // of it, and bytes after it belong to no field. + if (framed == false && from + 1 != total) { + return OwidParseResult.failed( + OwidParseStatus.MALFORMED_ENVELOPE); + } + 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); } diff --git a/src/main/java/com/swancommunity/owid/Version.java b/src/main/java/com/swancommunity/owid/Version.java index 4a7caed..85f77ff 100644 --- a/src/main/java/com/swancommunity/owid/Version.java +++ b/src/main/java/com/swancommunity/owid/Version.java @@ -30,10 +30,11 @@ public enum Version { * Marker used to indicate an optional OWID that is not present, inside a * larger framed byte array. * - *No OWID carries this version. A whole buffer holding nothing but the - * marker holds no identifier, so {@link Owid#parse(byte[])} refuses it as - * {@link OwidParseStatus#UNSUPPORTED_VERSION} rather than handing back - * something nothing has ever signed.
+ *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), diff --git a/src/test/java/com/swancommunity/owid/FramedReadTest.java b/src/test/java/com/swancommunity/owid/FramedReadTest.java index 81c2cfe..cc0465d 100644 --- a/src/test/java/com/swancommunity/owid/FramedReadTest.java +++ b/src/test/java/com/swancommunity/owid/FramedReadTest.java @@ -198,10 +198,48 @@ void framedFailuresUseTheSharedVocabulary() { new byte[0], Envelope.signature()))), OwidParseStatus.UNEXPECTED_END); - // The marker for an absent node is refused here too, because handing - // one back would be an OWID nothing had ever signed. - ParseAssert.failed(Owid.parse(ByteBuffer.wrap(Owid.emptyByteArray())), - OwidParseStatus.UNSUPPORTED_VERSION); + } + + /** + * 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"); } /** diff --git a/src/test/java/com/swancommunity/owid/ParseAssert.java b/src/test/java/com/swancommunity/owid/ParseAssert.java index 0db6408..0076b8a 100644 --- a/src/test/java/com/swancommunity/owid/ParseAssert.java +++ b/src/test/java/com/swancommunity/owid/ParseAssert.java @@ -69,4 +69,21 @@ static void failed(OwidParseResult result, OwidParseStatus expected) { 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 index 110de81..81ef33d 100644 --- a/src/test/java/com/swancommunity/owid/ParseContractTest.java +++ b/src/test/java/com/swancommunity/owid/ParseContractTest.java @@ -49,11 +49,8 @@ * cannot be, being {@link OwidParseStatus#INVALID_INPUT_TYPE}, which the * compiler already refuses, and * {@link OwidParseStatus#IMPLEMENTATION_CAPACITY_EXCEEDED}, which a Java byte - * array cannot reach on either surface. A third, - * {@link OwidParseStatus#MALFORMED_ENVELOPE}, is a backstop with no path to - * it while the byte count rule holds, and the framed surface does not apply - * that check at all. The reason is recorded on each of those members as - * well. + * array cannot reach on either surface. The reason is recorded on each of + * those members as well. */ class ParseContractTest { @@ -233,22 +230,25 @@ void disagreeingByteCountIsByteCountMismatch() { } /** - * The marker for an absent optional OWID is refused by the whole buffer - * read. + * 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 stands for the absence of an identifier rather than for one, and - * 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. - * That is the state the construction boundary exists to prevent, and it - * could never verify. A framed reader, which this library does not have, - * would still read the marker as the absence it means.
+ *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.
+ * + *Reading a whole buffer the marker has to be the whole of it, so + * bytes after it belong to no field.
*/ @Test - void emptyMarkerIsRefused() { - ParseAssert.failed(Owid.parse(Owid.emptyByteArray()), - OwidParseStatus.UNSUPPORTED_VERSION); + void emptyMarkerIsAnAbsentNodeAndNotAnOwid() { + ParseAssert.absentNode(Owid.parse(Owid.emptyByteArray())); + ParseAssert.absentNode(Owid.parse("AA==")); + ParseAssert.failed(Owid.parse(new byte[] {0, 1}), - OwidParseStatus.UNSUPPORTED_VERSION); + OwidParseStatus.MALFORMED_ENVELOPE); } /** From 5d8facf841ca36474823f1788d10128a88f6c6f0 Mon Sep 17 00:00:00 2001 From: James RosewellReading 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. 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. Read as a whole buffer the marker has to be the whole of it, - * and bytes after it are - * {@link OwidParseStatus#MALFORMED_ENVELOPE}.
+ * 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 */ diff --git a/src/main/java/com/swancommunity/owid/OwidParseResult.java b/src/main/java/com/swancommunity/owid/OwidParseResult.java index 20c8525..8c1863c 100644 --- a/src/main/java/com/swancommunity/owid/OwidParseResult.java +++ b/src/main/java/com/swancommunity/owid/OwidParseResult.java @@ -111,8 +111,8 @@ public OwidParseStatus getStatus() { *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. Reading a whole buffer this is the length of the buffer, - * because there the envelope is the whole of it.
+ * 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 diff --git a/src/main/java/com/swancommunity/owid/OwidParseStatus.java b/src/main/java/com/swancommunity/owid/OwidParseStatus.java index 7dd0cf1..06f7ac7 100644 --- a/src/main/java/com/swancommunity/owid/OwidParseStatus.java +++ b/src/main/java/com/swancommunity/owid/OwidParseStatus.java @@ -112,19 +112,15 @@ public enum OwidParseStatus { * fallback for the genuinely unclassified, not a substitute for naming a * failure that is already understood. * - *
One thing reaches it, being the marker for an absent node followed - * by bytes on the whole buffer contract, where the marker has to be the - * whole of the buffer and what follows belongs to no field.
- * - *The other 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.
+ *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, @@ -139,11 +135,11 @@ public enum OwidParseStatus { * date and no signature, and returning an OWID for it would put one in a * caller's hands that nothing had ever signed. * - *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. Reading a whole buffer the marker has to be the - * whole of it, and bytes after it are - * {@link #MALFORMED_ENVELOPE}.
+ *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 index 06e161f..d4d9633 100644 --- a/src/main/java/com/swancommunity/owid/OwidReader.java +++ b/src/main/java/com/swancommunity/owid/OwidReader.java @@ -74,14 +74,12 @@ static OwidParseResult read(byte[] buffer, int from, int total, // means a node is missing, which is why the caller is told that // rather than told the version is unknown. // - // Reading a frame the marker is consumed, so a caller walking a - // run of frames steps over the absent node and reads the one - // after it. Reading a whole buffer the marker has to be the whole - // of it, and bytes after it belong to no field. - if (framed == false && from + 1 != total) { - return OwidParseResult.failed( - OwidParseStatus.MALFORMED_ENVELOPE); - } + // 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) { diff --git a/src/test/java/com/swancommunity/owid/ParseContractTest.java b/src/test/java/com/swancommunity/owid/ParseContractTest.java index 81ef33d..930cec3 100644 --- a/src/test/java/com/swancommunity/owid/ParseContractTest.java +++ b/src/test/java/com/swancommunity/owid/ParseContractTest.java @@ -45,12 +45,14 @@ * *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}. Two members are not, and + * byte count cases by {@link PayloadLengthTest}. Three members are not, and * cannot be, being {@link OwidParseStatus#INVALID_INPUT_TYPE}, which the - * compiler already refuses, and + * compiler already refuses, * {@link OwidParseStatus#IMPLEMENTATION_CAPACITY_EXCEEDED}, which a Java byte - * array cannot reach on either surface. The reason is recorded on each of - * those members as well.
+ * 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 { @@ -239,16 +241,17 @@ void disagreeingByteCountIsByteCountMismatch() { * 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. * - *Reading a whole buffer the marker has to be the whole of it, so - * bytes after it belong to no field.
+ *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.failed(Owid.parse(new byte[] {0, 1}), - OwidParseStatus.MALFORMED_ENVELOPE); + ParseAssert.absentNode(Owid.parse(new byte[] {0, 1})); } /**