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:
+ *
+ * - duplicate member names in any object, compared after decoding escapes
+ * ({@link AssertionFailureReason#DUPLICATE_MEMBER});
+ * - at every level of the specification's Table 3.2, from the manifest root down to each
+ * assertion's {@code statement} and {@code binding}, a member name that is a case variant of a
+ * name listed for that level, or two member names that are case variants of each other
+ * ({@link AssertionFailureReason#AMBIGUOUS_MEMBER}); and
+ * - inside assertion objects, member names and strings with lone surrogates or Unicode
+ * noncharacters, and numbers that overflow an IEEE 754 double
+ * ({@link AssertionFailureReason#INVALID_JSON}).
+ *
+ * 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))