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 5e903498..db128da9 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java @@ -157,7 +157,12 @@ public Optional getSrtSigner() { * Checks to see if this has the structure of a Z-TDF in that it is a zip file * containing * a `manifest.json` and a `0.payload` - * + *

+ * The off-spec `0.manifest.json` this SDK wrote before the spec alignment is also + * accepted, matching {@link TDFReader}. Entries beyond those two are ignored rather + * than disqualifying: the spec fixes where the manifest lives, not what else the + * archive may hold. + * * @param channel A channel containing the bytes of the potential Z-TDF * @return `true` if */ @@ -169,11 +174,9 @@ public static boolean isTDF(SeekableByteChannel channel) { return false; } var entries = zipReader.getEntries(); - if (entries.size() != 2) { - return false; - } - return entries.stream().anyMatch(e -> "0.manifest.json".equals(e.getName())) - && entries.stream().anyMatch(e -> "0.payload".equals(e.getName())); + return entries.stream().anyMatch(e -> TDFWriter.TDF_MANIFEST_FILE_NAME.equals(e.getName()) + || TDFWriter.TDF_MANIFEST_FILE_NAME_OFFSPEC.equals(e.getName())) + && entries.stream().anyMatch(e -> TDFWriter.TDF_PAYLOAD_FILE_NAME.equals(e.getName())); } /** 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 6e9f32d2..85e1e69b 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TDFReader.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDFReader.java @@ -9,6 +9,7 @@ import java.util.stream.Collectors; import static io.opentdf.platform.sdk.TDFWriter.TDF_MANIFEST_FILE_NAME; +import static io.opentdf.platform.sdk.TDFWriter.TDF_MANIFEST_FILE_NAME_OFFSPEC; import static io.opentdf.platform.sdk.TDFWriter.TDF_PAYLOAD_FILE_NAME; /** @@ -26,14 +27,19 @@ public TDFReader(SeekableByteChannel tdf) throws SDKException, IOException { .stream() .collect(Collectors.toMap(ZipReader.Entry::getName, e -> e)); - if (!entries.containsKey(TDF_MANIFEST_FILE_NAME)) { + // The spec name wins over the off-spec one when an archive carries both, so a + // conformant entry is never passed over for a superseded one. + var manifest = entries.containsKey(TDF_MANIFEST_FILE_NAME) + ? entries.get(TDF_MANIFEST_FILE_NAME) + : entries.get(TDF_MANIFEST_FILE_NAME_OFFSPEC); + if (manifest == null) { throw new IllegalArgumentException("tdf doesn't contain a manifest"); } if (!entries.containsKey(TDF_PAYLOAD_FILE_NAME)) { throw new IllegalArgumentException("tdf doesn't contain a payload"); } - manifestEntry = entries.get(TDF_MANIFEST_FILE_NAME); + manifestEntry = manifest; payload = entries.get(TDF_PAYLOAD_FILE_NAME).getData(); } diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TDFWriter.java b/sdk/src/main/java/io/opentdf/platform/sdk/TDFWriter.java index 7137c232..774506ba 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TDFWriter.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDFWriter.java @@ -10,7 +10,21 @@ */ public class TDFWriter { public static final String TDF_PAYLOAD_FILE_NAME = "0.payload"; - public static final String TDF_MANIFEST_FILE_NAME = "0.manifest.json"; + + /** + * The manifest entry name given by the OpenTDF spec: + * opentdf.io/spec. + */ + public static final String TDF_MANIFEST_FILE_NAME = "manifest.json"; + + /** + * The manifest entry name this SDK wrote before the spec alignment. The {@code 0.} + * prefix is a holdover from an early design that anticipated several payload/manifest + * pairs per archive and never shipped. Readers still accept it; the writer no longer + * emits it. + * See platform#3513. + */ + public static final String TDF_MANIFEST_FILE_NAME_OFFSPEC = "0.manifest.json"; private final ZipWriter archiveWriter; public TDFWriter(OutputStream destination) { diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/SDKTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/SDKTest.java index 289d2f42..889e50fd 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/SDKTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/SDKTest.java @@ -10,9 +10,11 @@ import org.junit.jupiter.api.Test; import org.mockito.Mockito; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.channels.SeekableByteChannel; +import java.nio.charset.StandardCharsets; import java.util.Random; import static org.assertj.core.api.Assertions.assertThat; @@ -29,6 +31,51 @@ void testExaminingValidZTDF() throws IOException { } } + /** + * The spec names the manifest entry {@code manifest.json}; the fixture above is an + * archive from before the spec alignment, which names it {@code 0.manifest.json}. + * Both are recognized. + * See platform#3513. + */ + @Test + void testExaminingTDFWithSpecManifestName() throws IOException { + try (var chan = zipOf("0.payload", "manifest.json")) { + assertThat(SDK.isTDF(chan)).isTrue(); + } + } + + @Test + void testExaminingTDFWithAnExtraEntry() throws IOException { + try (var chan = zipOf("0.payload", "manifest.json", "something-else")) { + assertThat(SDK.isTDF(chan)).isTrue(); + } + } + + @Test + void testExaminingZipWithNoManifest() throws IOException { + try (var chan = zipOf("0.payload")) { + assertThat(SDK.isTDF(chan)).isFalse(); + } + } + + @Test + void testExaminingZipWithNoPayload() throws IOException { + try (var chan = zipOf("manifest.json")) { + assertThat(SDK.isTDF(chan)).isFalse(); + } + } + + /** Builds a zip holding the named entries; contents are irrelevant to {@link SDK#isTDF}. */ + private static SeekableInMemoryByteChannel zipOf(String... names) throws IOException { + var out = new ByteArrayOutputStream(); + var writer = new ZipWriter(out); + for (var name : names) { + writer.data(name, name.getBytes(StandardCharsets.UTF_8)); + } + writer.finish(); + return new SeekableInMemoryByteChannel(out.toByteArray()); + } + @Test void testReadingProtocolClient() { var platformServicesClient = mock(ProtocolClient.class); diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TDFReaderTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TDFReaderTest.java new file mode 100644 index 00000000..5fd3cc0f --- /dev/null +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFReaderTest.java @@ -0,0 +1,119 @@ +package io.opentdf.platform.sdk; + +import org.apache.commons.compress.utils.SeekableInMemoryByteChannel; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * The OpenTDF spec names the manifest entry {@code manifest.json}; this SDK wrote + * {@code 0.manifest.json} before the spec alignment, so the reader accepts either. + * See platform#3513. + */ +public class TDFReaderTest { + + /** + * Carries the fields the TDF manifest schema requires, so the fixtures below are + * manifests rather than arbitrary JSON. {@link TDFReader#manifest()} hands back the + * bytes without parsing them, so the literal is spelled out here. + */ + private static String manifestJson(String mimeType) { + return "{\"payload\":{\"type\":\"reference\",\"url\":\"" + TDFWriter.TDF_PAYLOAD_FILE_NAME + + "\",\"protocol\":\"zip\",\"isEncrypted\":true,\"mimeType\":\"" + mimeType + + "\"},\"encryptionInformation\":{\"type\":\"split\"}}"; + } + + /** + * What nearly every fixture here stores. These tests exercise the entry name, not + * manifest contents, so the same manifest serves whichever name it is filed under. + */ + private static final String MANIFEST = manifestJson("application/octet-stream"); + + /** + * Exists only for the test that must tell the two entries apart -- with identical + * content, it could not say which one the reader returned. + */ + private static final String OTHER_MANIFEST = manifestJson("text/plain"); + + private static final String PAYLOAD = "payload bytes"; + + /** Builds a zip holding exactly the given entries, in iteration order. */ + private static SeekableInMemoryByteChannel archiveOf(Map entries) throws IOException { + var out = new ByteArrayOutputStream(); + var writer = new ZipWriter(out); + for (var entry : entries.entrySet()) { + writer.data(entry.getKey(), entry.getValue().getBytes(StandardCharsets.UTF_8)); + } + writer.finish(); + return new SeekableInMemoryByteChannel(out.toByteArray()); + } + + private static Map entries(String... namesAndContents) { + var entries = new LinkedHashMap(); + for (int i = 0; i < namesAndContents.length; i += 2) { + entries.put(namesAndContents[i], namesAndContents[i + 1]); + } + return entries; + } + + @Test + void readsManifestUnderTheSpecName() throws IOException { + try (var tdf = archiveOf(entries( + TDFWriter.TDF_PAYLOAD_FILE_NAME, PAYLOAD, + "manifest.json", MANIFEST))) { + assertThat(new TDFReader(tdf).manifest()).isEqualTo(MANIFEST); + } + } + + @Test + void readsManifestUnderTheOffspecName() throws IOException { + try (var tdf = archiveOf(entries( + TDFWriter.TDF_PAYLOAD_FILE_NAME, PAYLOAD, + "0.manifest.json", MANIFEST))) { + assertThat(new TDFReader(tdf).manifest()).isEqualTo(MANIFEST); + } + } + + /** + * The two entries hold different manifests, so this cannot pass by reading whichever + * one the reader happened to pick. + */ + @Test + void prefersTheSpecNameWhenAnArchiveCarriesBoth() throws IOException { + try (var tdf = archiveOf(entries( + TDFWriter.TDF_PAYLOAD_FILE_NAME, PAYLOAD, + "0.manifest.json", OTHER_MANIFEST, + "manifest.json", MANIFEST))) { + assertThat(new TDFReader(tdf).manifest()).isEqualTo(MANIFEST); + } + } + + @Test + void rejectsAnArchiveWithNoManifestUnderEitherName() throws IOException { + try (var tdf = archiveOf(entries(TDFWriter.TDF_PAYLOAD_FILE_NAME, PAYLOAD))) { + assertThatThrownBy(() -> new TDFReader(tdf)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("tdf doesn't contain a manifest"); + } + } + + @Test + void readsThePayloadAlongsideASpecNamedManifest() throws IOException { + try (var tdf = archiveOf(entries( + TDFWriter.TDF_PAYLOAD_FILE_NAME, PAYLOAD, + "manifest.json", MANIFEST))) { + var reader = new TDFReader(tdf); + var buf = new byte[PAYLOAD.length()]; + + assertThat(reader.readPayloadBytes(buf)).isEqualTo(PAYLOAD.length()); + assertThat(new String(buf, StandardCharsets.UTF_8)).isEqualTo(PAYLOAD); + } + } +} diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/TDFWriterTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/TDFWriterTest.java index 2f5d22fc..9434472d 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/TDFWriterTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFWriterTest.java @@ -9,9 +9,10 @@ import java.io.FileOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.stream.Collectors; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; public class TDFWriterTest { @Test @@ -74,6 +75,30 @@ void simpleTDFCreate() throws IOException { fileOutStream.close(); } + /** + * The OpenTDF spec puts the manifest at the archive root under {@code manifest.json}; + * this SDK wrote {@code 0.manifest.json} before the spec alignment. Conformance test + * for the entry name the writer emits. + * See platform#3513. + */ + @Test + void writesTheManifestUnderTheSpecEntryName() throws IOException { + var out = new ByteArrayOutputStream(); + var writer = new TDFWriter(out); + try (var p = writer.payload()) { + new ByteArrayInputStream("payload bytes".getBytes(StandardCharsets.UTF_8)).transferTo(p); + } + writer.appendManifest("{\"payload\":{\"url\":\"0.payload\"}}"); + writer.finish(); + + try (var chan = new SeekableInMemoryByteChannel(out.toByteArray())) { + var names = new ZipReader(chan).getEntries().stream() + .map(ZipReader.Entry::getName) + .collect(Collectors.toList()); + assertThat(names).containsExactlyInAnyOrder("0.payload", "manifest.json"); + } + } + /** * The manifest is appended after the payload, so in a large TDF its local header offset * doesn't fit in a 32-bit central directory field. Uses the lowered zip64 threshold to run