diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/AssertionFailureReason.java b/sdk/src/main/java/io/opentdf/platform/sdk/AssertionFailureReason.java new file mode 100644 index 00000000..93f9ebc7 --- /dev/null +++ b/sdk/src/main/java/io/opentdf/platform/sdk/AssertionFailureReason.java @@ -0,0 +1,68 @@ +package io.opentdf.platform.sdk; + +import java.util.Optional; + +/** + * Why an assertion, or the manifest that carries it, was rejected. Every + * {@link SDK.AssertionFailureException} carries exactly one of these. + *

+ * The {@link #code() codes} are the stable strings from Section 9 of the ZTDF assertions + * specification. They are shared with the other OpenTDF SDKs and appear in error messages as + * {@code reason=}, so tools can match on them. + */ +public enum AssertionFailureReason { + /** The manifest is not I-JSON: invalid UTF-8, a lone surrogate, a noncharacter, or an out-of-range number. */ + INVALID_JSON("invalid-json"), + /** An object in the manifest has two members with the same name, compared after decoding escapes. */ + DUPLICATE_MEMBER("duplicate-member"), + /** An object has a case variant of a modelled member name, such as {@code ID} or {@code Assertions}. */ + AMBIGUOUS_MEMBER("ambiguous-member"), + /** The assertion's {@code id} is missing, empty, or not a JSON string. */ + INVALID_ID("invalid-id"), + /** The assertion's {@code statement} is not an object, or its {@code value} is neither a string nor an object. */ + INVALID_STATEMENT("invalid-statement"), + /** The {@code binding} is missing or malformed, has both binding forms or neither, or its JWS is malformed. */ + INVALID_BINDING("invalid-binding"), + /** The binding uses an unknown {@code method}, or is a binding object this SDK does not support. */ + UNSUPPORTED_BINDING("unsupported-binding"), + /** The JWS {@code alg} is missing, {@code none}, or not supported by this SDK. */ + UNSUPPORTED_ALGORITHM("unsupported-algorithm"), + /** The JWS {@code alg} differs from the algorithm of the selected verification key. */ + ALGORITHM_MISMATCH("algorithm-mismatch"), + /** An embedded {@code jwk} or {@code x5c} is symmetric, private, malformed, contradictory, or present in HMAC mode. */ + INVALID_EMBEDDED_KEY("invalid-embedded-key"), + /** An embedded {@code jwk} or {@code x5c} does not match the selected verification key. */ + SIGNER_NOT_PERMITTED("signer-not-permitted"), + /** The binding signature does not verify under the selected key. */ + SIGNATURE_INVALID("signature-invalid"), + /** The signed {@code assertionHash} or {@code assertionSig} does not match the recomputed value. */ + BINDING_MISMATCH("binding-mismatch"), + /** A configured verification key is empty, of the wrong type for its algorithm, or otherwise unusable. */ + CONFIGURATION_ERROR("configuration-error"); + + private final String code; + + AssertionFailureReason(String code) { + this.code = code; + } + + /** The stable reason string, for example {@code signer-not-permitted}. */ + public String code() { + return code; + } + + /** The reason with the given {@link #code() code}, if there is one. */ + public static Optional fromCode(String code) { + for (AssertionFailureReason reason : values()) { + if (reason.code.equals(code)) { + return Optional.of(reason); + } + } + return Optional.empty(); + } + + @Override + public String toString() { + return code; + } +} diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/AssertionJson.java b/sdk/src/main/java/io/opentdf/platform/sdk/AssertionJson.java new file mode 100644 index 00000000..4a58f7cb --- /dev/null +++ b/sdk/src/main/java/io/opentdf/platform/sdk/AssertionJson.java @@ -0,0 +1,38 @@ +package io.opentdf.platform.sdk; + +import com.google.gson.stream.JsonToken; + +import java.io.IOException; +import java.io.Reader; + +/** + * Parsing of assertion wire objects, as Section 3 of the ZTDF assertions specification defines + * it. + */ +final class AssertionJson { + + private AssertionJson() { + } + + /** + * Applies only the manifest-level checks of Section 3 to a whole manifest, without building + * the typed manifest. Malformed JSON is reported as {@code invalid-json} here; the manifest + * reader proper reports it as a malformed TDF. + * + * @throws SDK.AssertionFailureException on the first failure in document order + */ + static void checkManifest(Reader manifestJson) { + try (StrictJsonReader reader = new StrictJsonReader(manifestJson, StrictJsonReader.Mode.MANIFEST)) { + reader.skipValue(); + if (reader.peek() != JsonToken.END_DOCUMENT) { + throw invalidJson("unexpected content after the manifest"); + } + } catch (IOException | IllegalStateException | NumberFormatException e) { + throw invalidJson("the manifest is not valid JSON"); + } + } + + private static SDK.AssertionFailureException invalidJson(String detail) { + return new SDK.AssertionFailureException(null, AssertionFailureReason.INVALID_JSON, detail); + } +} diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java b/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java index 5b5edc39..49bd6dd3 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/Manifest.java @@ -11,6 +11,7 @@ import com.google.gson.JsonParseException; import com.google.gson.JsonSerializationContext; import com.google.gson.JsonSerializer; +import com.google.gson.JsonSyntaxException; import com.google.gson.TypeAdapter; import com.google.gson.TypeAdapterFactory; import com.google.gson.annotations.JsonAdapter; @@ -47,6 +48,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.lang.reflect.Type; +import java.nio.charset.CharacterCodingException; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -1179,17 +1181,29 @@ protected static Manifest readManifest(String manifestJson) { /** * Parses a manifest without materializing it as a {@link String}, which a * manifest with tens of millions of segments cannot be. + *

+ * The JSON must be strict (RFC 8259) and must meet the JSON profile of the ZTDF assertions + * specification, Section 3, which this checks in the same single pass: no duplicate member + * names in any object, no case variants of modelled member names (such as + * {@code Assertions} or an assertion's {@code ID}), and I-JSON content in every assertion. + * Those checks apply whether or not assertions are later verified. + * + * @throws SDK.AssertionFailureException if the manifest breaks that profile, or if + * {@code manifestJson} reports undecodable input */ protected static Manifest readManifest(Reader manifestJson) { FailureTrackingReader reader = new FailureTrackingReader(manifestJson); + StrictJsonReader jsonReader = new StrictJsonReader(reader, StrictJsonReader.Mode.MANIFEST); Manifest result; try { - result = gson.fromJson(reader, Manifest.class); - } catch (JsonParseException e) { - if (reader.failure != null) { - throw new SDKException("error reading manifest from zip file", reader.failure); + result = gson.fromJson(jsonReader, TypeToken.get(Manifest.class)); + if (result != null && jsonReader.peek() != JsonToken.END_DOCUMENT) { + throw new JsonSyntaxException("unexpected content after the manifest object"); } - throw e; + } catch (JsonParseException e) { + throw readFailure(reader, e); + } catch (IOException e) { + throw readFailure(reader, new JsonSyntaxException(e)); } if (result == null) { throw new IllegalArgumentException("Manifest is empty"); @@ -1198,6 +1212,25 @@ protected static Manifest readManifest(Reader manifestJson) { return result; } + /** + * Applies only the manifest-level parsing rules of the assertions specification, Section 3, + * without building the typed manifest. Malformed JSON is reported as {@code invalid-json}. + */ + static void checkManifestJson(String manifestJson) { + AssertionJson.checkManifest(new StringReader(manifestJson)); + } + + private static RuntimeException readFailure(FailureTrackingReader reader, JsonParseException e) { + if (reader.failure instanceof CharacterCodingException) { + return new SDK.AssertionFailureException(null, AssertionFailureReason.INVALID_JSON, + "the manifest is not valid UTF-8"); + } + if (reader.failure != null) { + return new SDKException("error reading manifest from zip file", reader.failure); + } + return e; + } + private static final class FailureTrackingReader extends Reader { private final Reader delegate; private IOException failure; diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java b/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java index b26f1f9e..8c3fea48 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java @@ -33,6 +33,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; @@ -224,6 +225,9 @@ public static boolean isTDF(SeekableByteChannel channel) { * @param tdfBytes A SeekableByteChannel containing the TDF data * @return The parsed {@link Manifest} object * @throws MalformedTDFException if the bytes are not a well formed TDF or its manifest is invalid + * @throws AssertionFailureException if the manifest is not valid UTF-8, or breaks the JSON + * profile the assertions specification requires (for example duplicate member names). + * This does not verify the assertions themselves. * @throws SDKException if an SDK-specific error occurs * @throws IOException if an I/O error occurs */ @@ -560,6 +564,109 @@ public static class AssertionException extends TamperException { public AssertionException(String errorMessage, String id) { super("assertion id: "+ id + "; " + errorMessage); } + + /** For subclasses that format the whole message themselves. */ + protected AssertionException(String errorMessage) { + super(errorMessage); + } + } + + /** + * {@link AssertionFailureException} is thrown when an assertion fails verification, or when + * the manifest that carries the assertions cannot be parsed under the JSON profile the + * assertions specification requires (duplicate member names, case variants of modelled + * member names, or non-I-JSON content in an assertion). + *

+ * It carries the exact {@code id} of the failing assertion when one could be read, and one + * {@link AssertionFailureReason}. The message contains {@code reason=}, and + * {@code id: } when the id is known. + */ + public static class AssertionFailureException extends AssertionException { + private final String assertionId; + private final AssertionFailureReason reason; + + public AssertionFailureException(String assertionId, AssertionFailureReason reason, String detail) { + super(formatMessage(assertionId, reason, detail)); + this.assertionId = assertionId; + this.reason = Objects.requireNonNull(reason, "reason"); + } + + /** + * The exact {@code id} of the failing assertion, or {@code null} when the failure is not + * tied to an assertion with a valid id (for example a duplicate member elsewhere in the + * manifest, or an assertion whose {@code id} is itself invalid). + */ + public String getAssertionId() { + return assertionId; + } + + public AssertionFailureReason getReason() { + return reason; + } + + /** Rendered ids are cut to this many code points, then marked with an ellipsis. */ + static final int MAX_RENDERED_ID = 256; + /** Details quote manifest content, such as member names and paths, so they are cut too. */ + static final int MAX_RENDERED_DETAIL = 512; + + private static String formatMessage(String assertionId, AssertionFailureReason reason, String detail) { + StringBuilder message = new StringBuilder("tdf: issue verifying assertions"); + if (assertionId != null) { + message.append(", id: "); + render(assertionId, MAX_RENDERED_ID, message); + } + message.append(", reason=").append(reason.code()); + if (detail != null && !detail.isEmpty()) { + message.append(": "); + render(detail, MAX_RENDERED_DETAIL, message); + } + return message.toString(); + } + + /** + * Appends untrusted manifest text for a message: {@code \} and the control characters + * U+0000 to U+001F and U+007F to U+009F escaped as in JSON, and at most + * {@code maxCodePoints} code points of the text, followed by an ellipsis when it is + * longer. An id read from a manifest then cannot start a new log line or end the message. + */ + static void render(String text, int maxCodePoints, StringBuilder out) { + int codePoints = 0; + for (int i = 0; i < text.length(); ) { + if (codePoints == maxCodePoints) { + out.append('\u2026'); + return; + } + int codePoint = text.codePointAt(i); + i += Character.charCount(codePoint); + codePoints++; + switch (codePoint) { + case '\\': + out.append("\\\\"); + break; + case '\b': + out.append("\\b"); + break; + case '\t': + out.append("\\t"); + break; + case '\n': + out.append("\\n"); + break; + case '\f': + out.append("\\f"); + break; + case '\r': + out.append("\\r"); + break; + default: + if (codePoint < 0x20 || (codePoint >= 0x7f && codePoint <= 0x9f)) { + out.append(String.format("\\u%04x", codePoint)); + } else { + out.appendCodePoint(codePoint); + } + } + } + } } /** diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/StrictJsonReader.java b/sdk/src/main/java/io/opentdf/platform/sdk/StrictJsonReader.java new file mode 100644 index 00000000..32f536b3 --- /dev/null +++ b/sdk/src/main/java/io/opentdf/platform/sdk/StrictJsonReader.java @@ -0,0 +1,534 @@ +package io.opentdf.platform.sdk; + +import com.google.gson.Strictness; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; + +import java.io.FilterReader; +import java.io.IOException; +import java.io.Reader; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * A strict {@link JsonReader} that applies the JSON profile of the ZTDF assertions specification + * (Section 3) while the document streams past, so the checks see every member before Gson's + * object model or a typed field can silently keep only one of two duplicates. + *

+ * Gson keeps the last of two members with the same name, and other parsers keep the first, so + * a manifest with duplicates means different things to different readers. This reader rejects: + *

+ * Every value has to pass through one of the overridden methods, including values Gson skips + * because the typed model has no field for them: {@link #skipValue()} walks them instead of + * skipping them blind. + *

+ * A failure outside any assertion throws at once. A failure inside an assertion is held until + * the assertion object ends, and then thrown with the assertion's exact {@code id} when it has + * one, so the first failure in document order is the one reported. + */ +final class StrictJsonReader extends JsonReader { + + /** What the top-level value is expected to be. */ + enum Mode { + /** A whole manifest: the root object holds {@code assertions}. */ + MANIFEST, + /** One assertion wire object. */ + ASSERTION, + /** Any JSON: only duplicate member names are checked. */ + GENERIC + } + + /** + * What an open object or array is. The object kinds with a level name are the levels of the + * specification's Table 3.2, each with its listed member names; the rest are not checked for + * case variants. + */ + private enum Kind { + ROOT("manifest root", "assertions", "encryptionInformation", "payload", "schemaVersion", "tdf_spec_version"), + PAYLOAD("payload", "type", "url", "protocol", "mimeType", "isEncrypted", "tdf_spec_version"), + ENCRYPTION_INFORMATION("encryptionInformation", "type", "policy", "keyAccess", "method", "integrityInformation"), + KEY_ACCESS("encryptionInformation.keyAccess[]", "type", "url", "protocol", "wrappedKey", "policyBinding", + "encryptedMetadata", "kid", "sid", "schemaVersion", "ephemeralPublicKey"), + POLICY_BINDING("encryptionInformation.keyAccess[].policyBinding", "alg", "hash"), + METHOD("encryptionInformation.method", "algorithm", "iv", "isStreamable"), + INTEGRITY_INFORMATION("encryptionInformation.integrityInformation", "rootSignature", "segmentHashAlg", + "segmentSizeDefault", "encryptedSegmentSizeDefault", "segments"), + ROOT_SIGNATURE("encryptionInformation.integrityInformation.rootSignature", "alg", "sig"), + SEGMENT("encryptionInformation.integrityInformation.segments[]", "hash", "segmentSize", "encryptedSegmentSize"), + ASSERTION("assertions[]", "id", "type", "scope", "appliesToState", "statement", "binding"), + STATEMENT("assertions[].statement", "format", "schema", "value"), + BINDING("assertions[].binding", "method", "signature", "bdo"), + KEY_ACCESS_ARRAY(null), + SEGMENTS_ARRAY(null), + ASSERTIONS(null), + OTHER(null); + + /** The level's name in Table 3.2, or null if this is not a checked level. */ + final String level; + final List listed; + /** Each listed name by its folded form, so a lookup replaces a scan of the list. */ + final Map listedByFold; + + Kind(String level, String... listed) { + this.level = level; + this.listed = List.of(listed); + Map byFold = new HashMap<>(); + for (String name : listed) { + byFold.put(fold(name), name); + } + this.listedByFold = Map.copyOf(byFold); + } + + boolean checked() { + return level != null; + } + } + + /** + * Names that are not rejected as case variants of a listed name (rule 1) although they are + * one, by level. Java SDK releases up to 0.7.5 wrote {@code IsStreamable}; readers give it no + * meaning. Two members that are case variants of each other are still rejected (rule 2). + */ + private static final Map> CASE_VARIANT_EXCEPTIONS = + Map.of(Kind.METHOD.level, List.of("IsStreamable")); + + /** Table 3.2: the listed member names of each checked level, by level name. */ + static Map> modelledNames() { + Map> table = new LinkedHashMap<>(); + for (Kind kind : Kind.values()) { + if (kind.checked()) { + table.put(kind.level, kind.listed); + } + } + return table; + } + + /** The rule 1 exceptions, by level name. */ + static Map> caseVariantExceptions() { + return CASE_VARIANT_EXCEPTIONS; + } + + /** Above this many members an object's names move from a list scan to a hash set. */ + private static final int LINEAR_SCAN_LIMIT = 16; + + private static final class Frame { + Kind kind; + boolean inAssertion; + /** The most recent member name read in this object; decides what a nested object is. */ + String lastName; + /** + * The member names seen so far: keyed by folded name at a checked level, so that one + * lookup finds both exact duplicates and case variants, and by exact name elsewhere. + */ + private final List keys = new ArrayList<>(); + private final List exactNames = new ArrayList<>(); + private Map nameMap; + // assertion frames only + int idMembers; + String idValue; + AssertionFailureReason pendingReason; + String pendingDetail; + + void reset(Kind kind, boolean inAssertion) { + this.kind = kind; + this.inAssertion = inAssertion; + lastName = null; + keys.clear(); + exactNames.clear(); + nameMap = null; + idMembers = 0; + idValue = null; + pendingReason = null; + pendingDetail = null; + } + + /** + * Records a member name under {@code key}. + * + * @return the exact name of an earlier member with the same key, or null if there is none + */ + String addName(String key, String exactName) { + if (nameMap != null) { + return nameMap.putIfAbsent(key, exactName); + } + int earlier = keys.indexOf(key); + if (earlier >= 0) { + return exactNames.get(earlier); + } + keys.add(key); + exactNames.add(exactName); + if (keys.size() > LINEAR_SCAN_LIMIT) { + nameMap = new HashMap<>(); + for (int i = 0; i < keys.size(); i++) { + nameMap.put(keys.get(i), exactNames.get(i)); + } + keys.clear(); + exactNames.clear(); + } + return null; + } + + /** The assertion's {@code id} if it has exactly one, and it is a valid nonempty string. */ + String exactId() { + if (idMembers != 1 || idValue == null || idValue.isEmpty() || iJsonStringProblem(idValue) != null) { + return null; + } + return idValue; + } + } + + private final Mode mode; + /** One entry per open object or array; entries are reused, so most objects allocate nothing. */ + private Frame[] frames = new Frame[16]; + private int depth; + /** Index of the open assertion object's frame, or -1 outside any assertion. */ + private int assertionDepth = -1; + + StrictJsonReader(Reader in, Mode mode) { + super(new LeadingByteOrderMarkCheck(in)); + this.mode = mode; + setStrictness(Strictness.STRICT); + } + + /** + * Rejects text that starts with U+FEFF. JSON text has no byte order mark (RFC 8259 Section + * 8.1), and Gson would otherwise skip one silently where other readers reject the document. + */ + private static final class LeadingByteOrderMarkCheck extends FilterReader { + private boolean checked; + + LeadingByteOrderMarkCheck(Reader in) { + super(in); + } + + @Override + public int read(char[] buffer, int offset, int length) throws IOException { + int read = super.read(buffer, offset, length); + if (!checked && read > 0) { + checked = true; + if (buffer[offset] == '\uFEFF') { + throw new SDK.AssertionFailureException(null, AssertionFailureReason.INVALID_JSON, + "the JSON text starts with a byte order mark"); + } + } + return read; + } + + @Override + public int read() throws IOException { + char[] one = new char[1]; + return read(one, 0, 1) < 0 ? -1 : one[0]; + } + } + + @Override + public void beginObject() throws IOException { + super.beginObject(); + Frame parent = top(); + Kind kind; + if (parent == null) { + kind = mode == Mode.MANIFEST ? Kind.ROOT : mode == Mode.ASSERTION ? Kind.ASSERTION : Kind.OTHER; + } else { + kind = objectKind(parent.kind, parent.lastName); + } + push(kind, kind == Kind.ASSERTION || (parent != null && parent.inAssertion)); + if (kind == Kind.ASSERTION) { + assertionDepth = depth - 1; + } + } + + @Override + public void endObject() throws IOException { + super.endObject(); + Frame frame = pop(); + if (frame.kind == Kind.ASSERTION) { + assertionDepth = -1; + if (frame.pendingReason != null) { + throw new SDK.AssertionFailureException(frame.exactId(), frame.pendingReason, frame.pendingDetail); + } + } + } + + @Override + public void beginArray() throws IOException { + super.beginArray(); + Frame parent = top(); + push(parent == null ? Kind.OTHER : arrayKind(parent.kind, parent.lastName), + parent != null && parent.inAssertion); + } + + @Override + public void endArray() throws IOException { + super.endArray(); + pop(); + } + + @Override + public String nextName() throws IOException { + String name = super.nextName(); + Frame frame = top(); + String folded = frame.kind.checked() ? fold(name) : name; + String earlier = frame.addName(folded, name); + if (earlier != null) { + if (earlier.equals(name)) { + fail(AssertionFailureReason.DUPLICATE_MEMBER, "duplicate member name \"" + name + "\" at " + getPath()); + } else { + // rule 2 + fail(AssertionFailureReason.AMBIGUOUS_MEMBER, "member names \"" + earlier + "\" and \"" + name + + "\" are case variants of each other at " + getPath()); + } + } + if (frame.kind.checked()) { + // rule 1 + String listed = frame.kind.listedByFold.get(folded); + if (listed != null && !listed.equals(name) + && !CASE_VARIANT_EXCEPTIONS.getOrDefault(frame.kind.level, List.of()).contains(name)) { + fail(AssertionFailureReason.AMBIGUOUS_MEMBER, + "member name \"" + name + "\" is a case variant of \"" + listed + "\" at " + getPath()); + } + } + if (frame.inAssertion) { + String problem = iJsonStringProblem(name); + if (problem != null) { + fail(AssertionFailureReason.INVALID_JSON, "member name with " + problem + " at " + getPath()); + } + } + frame.lastName = name; + if (frame.kind == Kind.ASSERTION && "id".equals(name)) { + frame.idMembers++; + } + return name; + } + + @Override + public String nextString() throws IOException { + JsonToken token = peek(); + String value = super.nextString(); + Frame frame = top(); + if (frame != null) { + if (frame.inAssertion) { + if (token == JsonToken.NUMBER) { + if (isOutOfDoubleRange(value)) { + fail(AssertionFailureReason.INVALID_JSON, + "number outside the IEEE 754 double range at " + getPreviousPath()); + } + } else { + String problem = iJsonStringProblem(value); + if (problem != null) { + fail(AssertionFailureReason.INVALID_JSON, "string with " + problem + " at " + getPreviousPath()); + } + } + } + if (frame.kind == Kind.ASSERTION && token == JsonToken.STRING && "id".equals(frame.lastName)) { + frame.idValue = value; + } + } + return value; + } + + /** + * Walks the value through the checking methods above rather than skipping it, so content + * the typed model ignores is checked too. Nesting is bounded by the reader's nesting limit. + */ + @Override + public void skipValue() throws IOException { + switch (peek()) { + case BEGIN_ARRAY: + beginArray(); + while (hasNext()) { + skipValue(); + } + endArray(); + break; + case BEGIN_OBJECT: + beginObject(); + while (hasNext()) { + nextName(); + skipValue(); + } + endObject(); + break; + case END_ARRAY: + endArray(); + break; + case END_OBJECT: + endObject(); + break; + case NAME: + nextName(); + break; + case STRING: + case NUMBER: + nextString(); + break; + case BOOLEAN: + nextBoolean(); + break; + case NULL: + nextNull(); + break; + case END_DOCUMENT: + default: + // nothing to skip; JsonReader leaves the end of the document in place too + break; + } + } + + private void fail(AssertionFailureReason reason, String detail) { + if (assertionDepth >= 0) { + Frame assertion = frames[assertionDepth]; + if (assertion.pendingReason == null) { + assertion.pendingReason = reason; + assertion.pendingDetail = detail; + } + return; + } + throw new SDK.AssertionFailureException(null, reason, detail); + } + + private Frame top() { + return depth == 0 ? null : frames[depth - 1]; + } + + private void push(Kind kind, boolean inAssertion) { + if (depth == frames.length) { + frames = Arrays.copyOf(frames, depth * 2); + } + Frame frame = frames[depth]; + if (frame == null) { + frame = new Frame(); + frames[depth] = frame; + } + frame.reset(kind, inAssertion); + depth++; + } + + private Frame pop() { + return frames[--depth]; + } + + /** The kind of an object that is the value of {@code name} in, or an element of, {@code parent}. */ + private static Kind objectKind(Kind parent, String name) { + switch (parent) { + case ROOT: + return "payload".equals(name) ? Kind.PAYLOAD + : "encryptionInformation".equals(name) ? Kind.ENCRYPTION_INFORMATION : Kind.OTHER; + case ENCRYPTION_INFORMATION: + return "method".equals(name) ? Kind.METHOD + : "integrityInformation".equals(name) ? Kind.INTEGRITY_INFORMATION : Kind.OTHER; + case KEY_ACCESS_ARRAY: + return Kind.KEY_ACCESS; + case KEY_ACCESS: + return "policyBinding".equals(name) ? Kind.POLICY_BINDING : Kind.OTHER; + case INTEGRITY_INFORMATION: + return "rootSignature".equals(name) ? Kind.ROOT_SIGNATURE : Kind.OTHER; + case SEGMENTS_ARRAY: + return Kind.SEGMENT; + case ASSERTIONS: + return Kind.ASSERTION; + case ASSERTION: + return "statement".equals(name) ? Kind.STATEMENT : "binding".equals(name) ? Kind.BINDING : Kind.OTHER; + default: + return Kind.OTHER; + } + } + + /** The kind of an array that is the value of {@code name} in {@code parent}. */ + private static Kind arrayKind(Kind parent, String name) { + if (parent == Kind.ROOT && "assertions".equals(name)) { + return Kind.ASSERTIONS; + } + if (parent == Kind.ENCRYPTION_INFORMATION && "keyAccess".equals(name)) { + return Kind.KEY_ACCESS_ARRAY; + } + if (parent == Kind.INTEGRITY_INFORMATION && "segments".equals(name)) { + return Kind.SEGMENTS_ARRAY; + } + return Kind.OTHER; + } + + /** + * {@code name} folded for case-variant comparison: ASCII letters to lower case, U+017F LATIN + * SMALL LETTER LONG S to {@code s}, U+212A KELVIN SIGN to {@code k}, and every other code + * point unchanged. Two different names are case variants exactly when their folded forms are + * equal, which is Go's {@code strings.EqualFold} against an ASCII name, how Go's struct + * decoder matches members. {@link String#equalsIgnoreCase} folds differently and must not be + * used here. + */ + static String fold(String name) { + StringBuilder folded = null; + for (int i = 0; i < name.length(); ) { + int codePoint = name.codePointAt(i); + int mapped; + if (codePoint >= 'A' && codePoint <= 'Z') { + mapped = codePoint + ('a' - 'A'); + } else if (codePoint == 0x017F) { + mapped = 's'; + } else if (codePoint == 0x212A) { + mapped = 'k'; + } else { + mapped = codePoint; + } + if (mapped != codePoint && folded == null) { + folded = new StringBuilder(name.length()).append(name, 0, i); + } + if (folded != null) { + folded.appendCodePoint(mapped); + } + i += Character.charCount(codePoint); + } + return folded == null ? name : folded.toString(); + } + + /** + * What makes {@code text} invalid in I-JSON (RFC 7493 Section 2.1), or null if nothing does: + * a lone surrogate, or a Unicode noncharacter (U+FDD0 to U+FDEF, and the last two code points + * of every plane). + */ + static String iJsonStringProblem(String text) { + for (int i = 0; i < text.length(); ) { + char c = text.charAt(i); + int codePoint; + if (Character.isHighSurrogate(c)) { + if (i + 1 >= text.length() || !Character.isLowSurrogate(text.charAt(i + 1))) { + return "a lone surrogate"; + } + codePoint = Character.toCodePoint(c, text.charAt(i + 1)); + i += 2; + } else if (Character.isLowSurrogate(c)) { + return "a lone surrogate"; + } else { + codePoint = c; + i++; + } + if ((codePoint >= 0xFDD0 && codePoint <= 0xFDEF) || (codePoint & 0xFFFE) == 0xFFFE) { + return "a Unicode noncharacter"; + } + } + return null; + } + + /** True if the JSON number text parses to an infinite double, such as {@code 1e400}. */ + static boolean isOutOfDoubleRange(String numberText) { + try { + return Double.isInfinite(Double.parseDouble(numberText)); + } catch (NumberFormatException e) { + // the reader only hands over well-formed JSON numbers, which always parse + return true; + } + } +} diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TDFReader.java b/sdk/src/main/java/io/opentdf/platform/sdk/TDFReader.java index bb92fe38..bcdf4c25 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TDFReader.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDFReader.java @@ -8,6 +8,8 @@ import java.io.InputStreamReader; import java.io.Reader; import java.nio.channels.SeekableByteChannel; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.stream.Collectors; @@ -67,7 +69,10 @@ static TDFReader open(SeekableByteChannel tdf) throws IOException { /** * Reads and validates the manifest. Malformed JSON, and JSON that does not have the shape of - * a manifest, is reported as a {@link SDK.MalformedTDFException}. + * a manifest, is reported as a {@link SDK.MalformedTDFException}. A manifest that is not valid + * UTF-8, or that breaks the JSON profile of the assertions specification (duplicate member + * names, case variants of modelled member names, non-I-JSON assertion content), is reported + * as a {@link SDK.AssertionFailureException}. */ Manifest readManifest() { try (Reader manifestJson = manifest()) { @@ -96,8 +101,12 @@ Reader manifest() { // no fixed size cap: manifests for very large payloads exceed 1 GiB. a hostile declared // size is still bounded by the archive, since the entry's stream throws where it ends try { - return new BufferedReader( - new InputStreamReader(manifestEntry.getData(), StandardCharsets.UTF_8), MANIFEST_BUFFER_SIZE); + // REPORT, not the default REPLACE: invalid UTF-8 must fail rather than turn silently + // into U+FFFD, which would hash and verify as if the writer had written it + CharsetDecoder utf8 = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + return new BufferedReader(new InputStreamReader(manifestEntry.getData(), utf8), MANIFEST_BUFFER_SIZE); } catch (InvalidZipException e) { throw malformed("error retrieving manifest from zip file", e); } catch (IOException e) { diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/StrictManifestParsingTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/StrictManifestParsingTest.java new file mode 100644 index 00000000..e67672d0 --- /dev/null +++ b/sdk/src/test/java/io/opentdf/platform/sdk/StrictManifestParsingTest.java @@ -0,0 +1,242 @@ +package io.opentdf.platform.sdk; + +import org.apache.commons.compress.utils.SeekableInMemoryByteChannel; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * The manifest reader applies the JSON profile of spec Section 3 to the whole manifest, before + * any typed field or JSON tree can drop one of two duplicates, and whether or not assertions are + * verified later. + */ +class StrictManifestParsingTest { + + private static String json(String singleQuoted) { + return singleQuoted.replace('\'', '"'); + } + + /** A minimal valid manifest with {@code rootExtra} (which must start with a comma) spliced in. */ + static String manifest(String rootExtra) { + return json("{'encryptionInformation':{'integrityInformation':{'encryptedSegmentSizeDefault':1048604," + + "'segmentSizeDefault':1048576,'rootSignature':{'alg':'HS256','sig':'c2ln'},'segmentHashAlg':'GMAC'," + + "'segments':[{'hash':'aGFzaDA=','segmentSize':4,'encryptedSegmentSize':32}]}," + + "'keyAccess':[{'protocol':'kas','type':'wrapped','url':'http://localhost:65432/kas','wrappedKey':'a2V5'}]," + + "'method':{'algorithm':'AES-256-GCM','isStreamable':true,'iv':'aXY='},'policy':'cG9saWN5','type':'split'}," + + "'payload':{'isEncrypted':true,'protocol':'zip','type':'reference','url':'0.payload'}" + + rootExtra + "}"); + } + + static String withAssertions(String assertions) { + return manifest(",'assertions':" + assertions); + } + + static Stream rejectedManifests() { + return Stream.of( + // duplicates anywhere, including outside the assertions, and escaped spellings + Arguments.of(manifest(",'payload':{}"), AssertionFailureReason.DUPLICATE_MEMBER, null), + Arguments.of(manifest(",'\\u0070ayload':{}"), AssertionFailureReason.DUPLICATE_MEMBER, null), + Arguments.of(manifest(",'extra':{'a':[{'b':1,'b':2}]}"), AssertionFailureReason.DUPLICATE_MEMBER, null), + Arguments.of(withAssertions("[]").replace("\"url\":\"0.payload\"", "\"url\":\"0.payload\",\"url\":\"x\""), + AssertionFailureReason.DUPLICATE_MEMBER, null), + Arguments.of(manifest(",'assertions':[],'assertions':[]"), AssertionFailureReason.DUPLICATE_MEMBER, null), + Arguments.of(withAssertions("[{'id':'a','id':'b'}]"), AssertionFailureReason.DUPLICATE_MEMBER, null), + Arguments.of(withAssertions("[{'id':'a','statement':{'format':'x','format':'y'}}]"), + AssertionFailureReason.DUPLICATE_MEMBER, "a"), + // case variants of modelled names, even with the exact name absent + Arguments.of(manifest(",'assertions':[],'Assertions':[]"), AssertionFailureReason.AMBIGUOUS_MEMBER, null), + Arguments.of(manifest(",'ASSERTIONS':[]"), AssertionFailureReason.AMBIGUOUS_MEMBER, null), + Arguments.of(withAssertions("[{'id':'classification','ID':'system-metadata'}]"), + AssertionFailureReason.AMBIGUOUS_MEMBER, "classification"), + Arguments.of(withAssertions("[{'ID':'system-metadata'}]"), AssertionFailureReason.AMBIGUOUS_MEMBER, null), + Arguments.of(withAssertions("[{'id':'a','binding':{'Signature':'x'}}]"), + AssertionFailureReason.AMBIGUOUS_MEMBER, "a"), + // non-I-JSON content inside an assertion + Arguments.of(withAssertions("[{'id':'a','statement':{'value':'\\uDEAD'}}]"), + AssertionFailureReason.INVALID_JSON, "a"), + Arguments.of(withAssertions("[{'id':'a','n':1e400}]"), AssertionFailureReason.INVALID_JSON, "a"), + // later assertions are checked too, and report their own id + Arguments.of(withAssertions("[{'id':'a'},{'id':'b','Id':'c'}]"), AssertionFailureReason.AMBIGUOUS_MEMBER, "b")); + } + + @ParameterizedTest + @MethodSource("rejectedManifests") + void rejects(String manifest, AssertionFailureReason reason, String id) { + assertThatThrownBy(() -> Manifest.readManifest(manifest)) + .isInstanceOfSatisfying(SDK.AssertionFailureException.class, e -> { + assertThat(e.getReason()).isEqualTo(reason); + assertThat(e.getAssertionId()).isEqualTo(id); + assertThat(e).hasMessageContaining("reason=" + reason.code()); + }); + // the manifest-only check used by the shared vectors agrees + assertThatThrownBy(() -> Manifest.checkManifestJson(manifest)) + .isInstanceOfSatisfying(SDK.AssertionFailureException.class, + e -> assertThat(e.getReason()).isEqualTo(reason)); + } + + @ParameterizedTest + @ValueSource(strings = { + "", + // the I-JSON checks are required only inside assertions + ",'extra':{'n':1e400,'s':'\\uDEAD'}", + // case variants are only checked at the four modelled levels + ",'payload2':{'Assertions':1},'assertions':[{'id':'a','statement':{'value':{'ID':1}},'extensions':{'Binding':1}}]" + }) + void accepts(String rootExtra) { + var manifest = manifest(rootExtra); + assertThatCode(() -> Manifest.readManifest(manifest)).doesNotThrowAnyException(); + assertThatCode(() -> Manifest.checkManifestJson(manifest)).doesNotThrowAnyException(); + } + + @ParameterizedTest + @ValueSource(strings = {",'a':NaN", ",'a':'x'//", ",'a':'x',", ",a:1", ",'a':'\\x'", "} {"}) + void readsOnlyStrictJson(String rootExtra) { + var manifest = manifest(rootExtra); + assertThatThrownBy(() -> Manifest.readManifest(manifest)).isInstanceOf(com.google.gson.JsonParseException.class); + } + + @Test + void reportsInvalidUtf8AsInvalidJson() throws IOException { + var valid = withAssertions("[{'id':'a','statement':{'value':'XY'}}]").getBytes(StandardCharsets.UTF_8); + var invalid = valid.clone(); + int at = new String(valid, StandardCharsets.UTF_8).indexOf("XY"); + invalid[at] = (byte) 0xC3; // a lead byte followed by an ASCII byte is malformed + assertThatCode(() -> SDK.readManifest(new SeekableInMemoryByteChannel(tdfWithManifest(valid)))) + .doesNotThrowAnyException(); + assertThatThrownBy(() -> SDK.readManifest(new SeekableInMemoryByteChannel(tdfWithManifest(invalid)))) + .isInstanceOfSatisfying(SDK.AssertionFailureException.class, + e -> assertThat(e.getReason()).isEqualTo(AssertionFailureReason.INVALID_JSON)); + } + + static byte[] tdfWithManifest(byte[] manifest) throws IOException { + var out = new ByteArrayOutputStream(); + var writer = new TDFWriter(out); + try (var payload = writer.payload()) { + payload.write(new byte[32]); + } + try (var manifestOut = writer.manifest()) { + manifestOut.write(manifest); + } + writer.finish(); + return out.toByteArray(); + } + + @Test + void rejectsAByteOrderMark() throws IOException { + var withBom = ("\uFEFF" + manifest("")).getBytes(StandardCharsets.UTF_8); + assertThatThrownBy(() -> SDK.readManifest(new SeekableInMemoryByteChannel(tdfWithManifest(withBom)))) + .isInstanceOfSatisfying(SDK.AssertionFailureException.class, + e -> assertThat(e.getReason()).isEqualTo(AssertionFailureReason.INVALID_JSON)); + assertThatThrownBy(() -> Manifest.checkManifestJson("\uFEFF" + manifest(""))) + .isInstanceOfSatisfying(SDK.AssertionFailureException.class, + e -> assertThat(e.getReason()).isEqualTo(AssertionFailureReason.INVALID_JSON)); + // a byte order mark is ordinary content anywhere else + assertThatCode(() -> Manifest.readManifest(manifest(",'extra':'\uFEFF'"))).doesNotThrowAnyException(); + } + + static Stream wholeManifestCaseVariants() { + String twoSegments = "[{\"hash\":\"aGFzaDA=\",\"segmentSize\":4,\"encryptedSegmentSize\":32}," + + "{\"Hash\":\"aGFzaDE=\",\"segmentSize\":4,\"encryptedSegmentSize\":32}]"; + String manifest = manifest(""); + return Stream.of( + // rule 1: a variant of a listed name, with the listed member absent + Arguments.of("Payload at the root", manifest.replace("\"payload\":", "\"Payload\":")), + Arguments.of("URL in a key access object", manifest.replace("\"url\":\"http", "\"URL\":\"http")), + Arguments.of("SchemaVersion at the root", manifest(",'SchemaVersion':'4.3.0'")), + Arguments.of("long s in segmentHashAlg", manifest.replace("\"segmentHashAlg\"", "\"\u017fegmentHashAlg\"")), + Arguments.of("Hash in the second segment", manifest.replace( + "[{\"hash\":\"aGFzaDA=\",\"segmentSize\":4,\"encryptedSegmentSize\":32}]", twoSegments)), + Arguments.of("ALG in a policy binding", manifest.replace("\"wrappedKey\":\"a2V5\"", + "\"wrappedKey\":\"a2V5\",\"policyBinding\":{\"ALG\":\"HS256\",\"hash\":\"aA==\"}")), + Arguments.of("IV in the method", manifest.replace("\"iv\":", "\"IV\":")), + Arguments.of("Sig in the root signature", manifest.replace("\"sig\":", "\"Sig\":")), + Arguments.of("MimeType in the payload", manifest.replace("\"url\":\"0.payload\"", + "\"url\":\"0.payload\",\"MimeType\":\"text/plain\"")), + Arguments.of("Policy in encryptionInformation", manifest.replace("\"policy\":", "\"Policy\":")), + Arguments.of("KID in a key access object", manifest.replace("\"wrappedKey\"", "\"KID\":\"k\",\"wrappedKey\"")), + Arguments.of("tdf_spec_Version in the payload", manifest.replace("\"url\":\"0.payload\"", + "\"url\":\"0.payload\",\"tdf_spec_Version\":\"4.3.0\"")), + // rule 2: two names that are case variants of each other, neither listed + Arguments.of("foo and Foo at the root", manifest(",'foo':1,'Foo':2")), + Arguments.of("Extra and extra in a segment", manifest.replace("\"segmentSize\":4", + "\"segmentSize\":4,\"Extra\":1,\"extra\":2")), + Arguments.of("IsStreamable with isStreamable", + manifest.replace("\"isStreamable\":true", "\"isStreamable\":true,\"IsStreamable\":true"))); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("wholeManifestCaseVariants") + void rejectsCaseVariantsAtEveryModelledLevel(String name, String manifest) { + assertThatThrownBy(() -> Manifest.readManifest(manifest)) + .isInstanceOfSatisfying(SDK.AssertionFailureException.class, + e -> assertThat(e.getReason()).isEqualTo(AssertionFailureReason.AMBIGUOUS_MEMBER)); + assertThatThrownBy(() -> Manifest.checkManifestJson(manifest)) + .isInstanceOfSatisfying(SDK.AssertionFailureException.class, + e -> assertThat(e.getReason()).isEqualTo(AssertionFailureReason.AMBIGUOUS_MEMBER)); + } + + static Stream caseVariantsOutsideTheModelledLevels() { + String manifest = manifest(""); + return Stream.of( + // the one legacy exception to rule 1 + Arguments.of("IsStreamable alone", + manifest.replace("\"isStreamable\":true", "\"IsStreamable\":true")), + Arguments.of("case pairs inside statement.value", + withAssertions("[{'id':'a','statement':{'value':{'a':1,'A':2,'ID':3,'id':4}}}]")), + Arguments.of("case pairs inside an unlisted member", manifest(",'extra':{'foo':1,'Foo':2,'Payload':3}")), + Arguments.of("case pairs inside an unlisted member of a key access object", + manifest.replace("\"wrappedKey\"", "\"extra\":{\"a\":1,\"A\":2},\"wrappedKey\"")), + Arguments.of("a dotless i is not a variant of id", withAssertions("[{'id':'a','\u0131d':'x'}]")), + Arguments.of("a different length is not a variant", manifest(",'payloads':1,'Payloads2':2"))); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("caseVariantsOutsideTheModelledLevels") + void acceptsCaseVariantsOutsideTheModelledLevels(String name, String manifest) { + assertThatCode(() -> Manifest.readManifest(manifest)).doesNotThrowAnyException(); + assertThatCode(() -> Manifest.checkManifestJson(manifest)).doesNotThrowAnyException(); + } + + @Test + void readsTheJava075GoldenTdfWithIsStreamable() throws IOException { + // the xtest golden file, when this checkout sits next to opentdf/tests + var golden = java.nio.file.Path.of("..", "..", "tests", "xtest", "golden", + "xstext-java-v0.7.5-94b161d53-DSP2.0.2_and_2.0.3.tdf"); + org.junit.jupiter.api.Assumptions.assumeTrue(java.nio.file.Files.isRegularFile(golden), + "golden TDF not available at " + golden.toAbsolutePath()); + try (var channel = java.nio.channels.FileChannel.open(golden)) { + var manifest = SDK.readManifest(channel); + assertThat(manifest.encryptionInformation.method.algorithm).isNotNull(); + } + try (var reader = TDFReader.open(java.nio.channels.FileChannel.open(golden)).manifest()) { + var text = new java.io.StringWriter(); + reader.transferTo(text); + assertThat(text.toString()).contains("\"IsStreamable\""); + } + } + + @Test + void longSegmentListsStayLinear() { + var segments = new StringBuilder("["); + for (int i = 0; i < 200_000; i++) { + segments.append(i == 0 ? "" : ",").append("{\"hash\":\"aGFzaDA=\",\"segmentSize\":4,\"encryptedSegmentSize\":32}"); + } + String manifest = manifest("").replace( + "[{\"hash\":\"aGFzaDA=\",\"segmentSize\":4,\"encryptedSegmentSize\":32}]", segments.append("]")); + long start = System.nanoTime(); + assertThat(Manifest.readManifest(manifest).encryptionInformation.integrityInformation.segments).hasSize(200_000); + // generous: a quadratic check would take minutes here + assertThat(java.time.Duration.ofNanos(System.nanoTime() - start)).isLessThan(java.time.Duration.ofSeconds(20)); + } +} diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java index cbc497de..6057cd94 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java @@ -26,6 +26,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; import java.io.StringWriter; import java.nio.charset.StandardCharsets; import java.security.KeyPair; @@ -1607,14 +1608,21 @@ void loadTDFReportsHostileArchivesAsMalformed() { * size fails when the manifest is read, an oversized compressed size when the archive is opened. */ @Test - void loadTDFReportsAManifestRunningPastTheArchiveAsMalformed() { - // the manifest opens a JSON comment, so the parser reads on to the end of the archive - // looking for its close, rather than failing at the first byte past the entry - var uncompressedPastTheEnd = HostileZips.tdfWith("/*", e -> e.uncompressedSize = 0x7FFFFFFFL); + void loadTDFReportsAManifestRunningPastTheArchiveAsMalformed() throws IOException { + // the entry runs on through the central directory, whose binary fields are not UTF-8, + // and the manifest decoder refuses them rather than substituting U+FFFD + var uncompressedPastTheEnd = HostileZips.tdfWith("{\"a\":\"", e -> e.uncompressedSize = 0x7FFFFFFFL); assertThatThrownBy(() -> loadTDFFrom(uncompressedPastTheEnd)) - .isInstanceOf(SDK.MalformedTDFException.class) - .hasCauseInstanceOf(InvalidZipException.class) - .hasMessageContaining("Archive ended"); + .isInstanceOfSatisfying(SDK.AssertionFailureException.class, e -> + assertThat(e.getReason()).isEqualTo(AssertionFailureReason.INVALID_JSON)); + // and the entry itself still ends in an error at the end of the archive, not a short read + var manifestEntry = new ZipReader(new SeekableInMemoryByteChannel(uncompressedPastTheEnd)).getEntries() + .stream().filter(e -> e.getName().equals(TDFWriter.TDF_MANIFEST_FILE_NAME)).findFirst().orElseThrow(); + assertThatThrownBy(() -> { + try (var data = manifestEntry.getData()) { + data.transferTo(OutputStream.nullOutputStream()); + } + }).isInstanceOf(InvalidZipException.class).hasMessageContaining("Archive ended"); var storedPastTheEnd = HostileZips.tdfWith("{}", e -> e.compressedSize = 0x7FFFFFFFL); assertThatThrownBy(() -> loadTDFFrom(storedPastTheEnd))