From 487dedc60b86f5131c671102024981b66d0a7c31 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Mon, 19 Oct 2026 12:56:11 -0400 Subject: [PATCH] feat(sdk): add opt-in unprefixed manifest entry name Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Paul Flynn --- .../java/io/opentdf/platform/Command.java | 6 +- .../java/io/opentdf/platform/CommandTest.java | 13 +++ .../java/io/opentdf/platform/sdk/Config.java | 12 +++ .../java/io/opentdf/platform/sdk/TDF.java | 5 +- .../io/opentdf/platform/sdk/TDFWriter.java | 24 +++-- .../io/opentdf/platform/sdk/ConfigTest.java | 6 ++ .../java/io/opentdf/platform/sdk/TDFTest.java | 91 +++++++++++++++++++ .../opentdf/platform/sdk/TDFWriterTest.java | 20 ++++ 8 files changed, 166 insertions(+), 11 deletions(-) diff --git a/cmdline/src/main/java/io/opentdf/platform/Command.java b/cmdline/src/main/java/io/opentdf/platform/Command.java index b073b9551..de7049a27 100644 --- a/cmdline/src/main/java/io/opentdf/platform/Command.java +++ b/cmdline/src/main/java/io/opentdf/platform/Command.java @@ -259,7 +259,8 @@ void encrypt( "--encap-key-type" }, defaultValue = Option.NULL_VALUE, description = "Preferred key access key wrap algorithm, one of ${COMPLETION-CANDIDATES}") Optional encapKeyType, @Option(names = { "--mime-type" }, defaultValue = Option.NULL_VALUE) Optional mimeType, @Option(names = { "--with-assertions" }, defaultValue = Option.NULL_VALUE) Optional assertion, - @Option(names = { "--with-target-mode" }, defaultValue = Option.NULL_VALUE) Optional targetMode) + @Option(names = { "--with-target-mode" }, defaultValue = Option.NULL_VALUE) Optional targetMode, + @Option(names = { "--unprefixed-manifest-name" }, description = "Use the spec manifest entry name (manifest.json)") boolean unprefixedManifestName) throws IOException, AutoConfigureException { @@ -315,6 +316,9 @@ void encrypt( attributes.ifPresent(s -> configs.add(Config.withDataAttributes(s.split(",")))); targetMode.map(Config::withTargetMode).ifPresent(configs::add); + if (unprefixedManifestName) { + configs.add(Config.withUnprefixedManifestName()); + } var tdfConfig = Config.newTDFConfig(configs.toArray(Consumer[]::new)); try (var in = file.isEmpty() ? new BufferedInputStream(System.in) : new FileInputStream(file.get())) { try (var out = new BufferedOutputStream(System.out)) { diff --git a/cmdline/src/test/java/io/opentdf/platform/CommandTest.java b/cmdline/src/test/java/io/opentdf/platform/CommandTest.java index 8995271dd..b94d060a6 100644 --- a/cmdline/src/test/java/io/opentdf/platform/CommandTest.java +++ b/cmdline/src/test/java/io/opentdf/platform/CommandTest.java @@ -63,6 +63,19 @@ void encrypt_withoutCredentials_failsWithMissingPlatformEndpoint() { assertThat(err.toString()).contains("Missing required option: '--platform-endpoint='"); } + @Test + void encrypt_unprefixedManifestNameFlag_acceptedByPicocli() { + StringWriter err = new StringWriter(); + CommandLine cli = new CommandLine(new Command()); + cli.setErr(new PrintWriter(err)); + + int code = cli.execute("encrypt", "-k", "https://kas.example.com", "-f", "/dev/null", "--unprefixed-manifest-name"); + + // Picocli accepts the flag; the command fails later on missing credentials, not unknown option. + assertThat(code).isEqualTo(CommandLine.ExitCode.USAGE); + assertThat(err.toString()).contains("Missing required option: '--platform-endpoint='"); + } + @Test void supports_withoutCredentials_stillExits0() { // Regression sentinel: tdf supports must not require --client-id/--client-secret/--platform-endpoint. diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Config.java b/sdk/src/main/java/io/opentdf/platform/sdk/Config.java index f4dbc4f5a..ee236a3be 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/Config.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/Config.java @@ -194,6 +194,7 @@ public static class TDFConfig { public boolean hexEncodeRootAndSegmentHashes; public boolean renderVersionInfoInManifest; public boolean systemMetadataAssertion; + public boolean unprefixedManifestName; public TDFConfig() { this.autoconfigure = true; @@ -211,6 +212,7 @@ public TDFConfig() { this.hexEncodeRootAndSegmentHashes = false; this.renderVersionInfoInManifest = true; this.systemMetadataAssertion = false; + this.unprefixedManifestName = false; } } @@ -325,6 +327,16 @@ public static Consumer withTargetMode(String targetVersion) { }; } + /** + * Stores the manifest under the zip entry name manifest.json, the name the TDF spec defines, + * instead of the legacy 0.manifest.json written by default. Readers that predate manifest.json + * support can't open the result. A pre-4.3.0 {@link #withTargetMode(String)} overrides this + * option and keeps the legacy name. + */ + public static Consumer withUnprefixedManifestName() { + return (TDFConfig config) -> config.unprefixedManifestName = true; + } + public static Consumer WithWrappingKeyAlg(KeyType keyType) { return (TDFConfig config) -> config.wrappingKeyType = keyType; } diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java index fddead945..c018420be 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDF.java @@ -648,7 +648,10 @@ TDFObject createTDF(InputStream payload, OutputStream outputStream, Config.TDFCo tdfObject.prepareManifest(tdfConfig, splits, metadataIv); long encryptedSegmentSize = (long) tdfConfig.defaultSegmentSize + kGcmIvSize + AesGcm.GCM_TAG_LENGTH; - TDFWriter tdfWriter = new TDFWriter(outputStream); + String manifestFileName = (tdfConfig.unprefixedManifestName && !tdfConfig.hexEncodeRootAndSegmentHashes) + ? TDFWriter.TDF_MANIFEST_FILE_NAME_SPEC + : TDFWriter.TDF_MANIFEST_FILE_NAME; + TDFWriter tdfWriter = new TDFWriter(outputStream, manifestFileName); ByteArrayOutputStream aggregateHash = new ByteArrayOutputStream(); byte[] readBuf = new byte[tdfConfig.defaultSegmentSize]; 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 93046f838..85ca443fb 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/TDFWriter.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/TDFWriter.java @@ -12,26 +12,31 @@ public class TDFWriter { public static final String TDF_PAYLOAD_FILE_NAME = "0.payload"; /** - * The manifest entry name this SDK writes. The {@code 0.} prefix is a holdover from an - * early design that anticipated several payload/manifest pairs per archive and never - * shipped; the spec names the entry {@link #TDF_MANIFEST_FILE_NAME_SPEC}. Changing what - * the writer emits is a breaking file-format change and is left to a separate change. - * See platform#3513. + * The legacy manifest entry name this SDK writes by default. The {@code 0.} prefix is a + * holdover from an early design that anticipated several payload/manifest pairs per + * archive and never shipped. The unprefixed name {@link #TDF_MANIFEST_FILE_NAME_SPEC} + * is opt-in. See platform#3513. */ public static final String TDF_MANIFEST_FILE_NAME = "0.manifest.json"; /** * The manifest entry name given by the OpenTDF spec: - * opentdf.io/spec. Read-side only -- - * {@link TDFReader} accepts it so archives from spec-conformant implementations can be - * read, and the writer does not yet emit it. + * opentdf.io/spec. + * {@link TDFReader} accepts it, and the writer emits it when + * {@link Config#withUnprefixedManifestName()} is set. */ public static final String TDF_MANIFEST_FILE_NAME_SPEC = "manifest.json"; private final ZipWriter archiveWriter; + private final String manifestFileName; public TDFWriter(OutputStream destination) { + this(destination, TDF_MANIFEST_FILE_NAME); + } + + TDFWriter(OutputStream destination, String manifestFileName) { this.archiveWriter = new ZipWriter(destination); + this.manifestFileName = manifestFileName; } /** @@ -39,10 +44,11 @@ public TDFWriter(OutputStream destination) { */ TDFWriter(OutputStream destination, long maxNonZip64Value) { this.archiveWriter = new ZipWriter(destination, maxNonZip64Value); + this.manifestFileName = TDF_MANIFEST_FILE_NAME; } public void appendManifest(String manifest) throws IOException { - this.archiveWriter.data(TDF_MANIFEST_FILE_NAME, manifest.getBytes(StandardCharsets.UTF_8)); + this.archiveWriter.data(manifestFileName, manifest.getBytes(StandardCharsets.UTF_8)); } public OutputStream payload() throws IOException { diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/ConfigTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/ConfigTest.java index 705271330..5de2a0c04 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/ConfigTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/ConfigTest.java @@ -83,4 +83,10 @@ void withMimeType_shouldSetMimeType() { Config.TDFConfig config = Config.newTDFConfig(Config.withMimeType(mimeType)); assertEquals(mimeType, config.mimeType); } + + @Test + void withUnprefixedManifestName_shouldSetUnprefixedManifestName() { + Config.TDFConfig config = Config.newTDFConfig(Config.withUnprefixedManifestName()); + assertTrue(config.unprefixedManifestName); + } } \ No newline at end of file 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 aa5b38e6b..c245a851d 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java @@ -1468,6 +1468,97 @@ void testAssertionWithoutIdGeneratesUUID() throws Exception { assertThat(assertionWithId.id).isEqualTo("explicit-id"); } + @Test + void unprefixedManifestNameRoundTrips() throws Exception { + Config.TDFConfig config = Config.newTDFConfig( + Config.withAutoconfigure(false), + Config.withKasInformation(getSingleRSAKASInfo()), + Config.withUnprefixedManifestName()); + + String plainText = "test data for unprefixed manifest"; + InputStream plainTextInputStream = new ByteArrayInputStream(plainText.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream tdfOutputStream = new ByteArrayOutputStream(); + + TDF tdf = new TDF( + new FakeServicesBuilder().setKas(kas) + .setKeyAccessServerRegistryService(kasRegistryService).build()); + tdf.createTDF(plainTextInputStream, tdfOutputStream, config); + + try (var chan = new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray())) { + var names = new ZipReader(chan).getEntries().stream() + .map(ZipReader.Entry::getName) + .collect(Collectors.toList()); + assertThat(names).contains("manifest.json"); + assertThat(names).doesNotContain("0.manifest.json"); + + var reader = tdf.loadTDF(chan, platformUrl); + var unwrappedData = new ByteArrayOutputStream(); + reader.readPayload(unwrappedData); + assertThat(unwrappedData.toString(StandardCharsets.UTF_8)).isEqualTo(plainText); + } + } + + @Test + void legacyTargetModeOverridesUnprefixedManifestName() throws Exception { + Consumer[] optionsFirstOrder = new Consumer[]{ + Config.withAutoconfigure(false), + Config.withKasInformation(getSingleRSAKASInfo()), + Config.withUnprefixedManifestName(), + Config.withTargetMode("4.2.2") + }; + Consumer[] optionsSecondOrder = new Consumer[]{ + Config.withAutoconfigure(false), + Config.withKasInformation(getSingleRSAKASInfo()), + Config.withTargetMode("4.2.2"), + Config.withUnprefixedManifestName() + }; + + for (Consumer[] options : new Consumer[][]{optionsFirstOrder, optionsSecondOrder}) { + Config.TDFConfig config = Config.newTDFConfig(options); + + String plainText = "test data"; + InputStream plainTextInputStream = new ByteArrayInputStream(plainText.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream tdfOutputStream = new ByteArrayOutputStream(); + + TDF tdf = new TDF( + new FakeServicesBuilder().setKas(kas) + .setKeyAccessServerRegistryService(kasRegistryService).build()); + tdf.createTDF(plainTextInputStream, tdfOutputStream, config); + + try (var chan = new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray())) { + var names = new ZipReader(chan).getEntries().stream() + .map(ZipReader.Entry::getName) + .collect(Collectors.toList()); + assertThat(names).contains("0.manifest.json"); + assertThat(names).doesNotContain("manifest.json"); + } + } + } + + @Test + void defaultManifestNameIsUnchanged() throws Exception { + Config.TDFConfig config = Config.newTDFConfig( + Config.withAutoconfigure(false), + Config.withKasInformation(getSingleRSAKASInfo())); + + String plainText = "test data for default manifest"; + InputStream plainTextInputStream = new ByteArrayInputStream(plainText.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream tdfOutputStream = new ByteArrayOutputStream(); + + TDF tdf = new TDF( + new FakeServicesBuilder().setKas(kas) + .setKeyAccessServerRegistryService(kasRegistryService).build()); + tdf.createTDF(plainTextInputStream, tdfOutputStream, config); + + try (var chan = new SeekableInMemoryByteChannel(tdfOutputStream.toByteArray())) { + var names = new ZipReader(chan).getEntries().stream() + .map(ZipReader.Entry::getName) + .collect(Collectors.toList()); + assertThat(names).contains("0.manifest.json"); + assertThat(names).doesNotContain("manifest.json"); + } + } + @Nonnull private static Config.KASInfo[] getKASInfos(Predicate filter) { var kasInfos = new ArrayList(); 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 2f5d22fc5..71b3e5761 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/TDFWriterTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/TDFWriterTest.java @@ -9,7 +9,9 @@ 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; @@ -101,4 +103,22 @@ void readsBackAManifestWrittenPastTheZip64Boundary() throws IOException { assertEquals(payload, new String(payloadBytes, StandardCharsets.UTF_8)); } } + + @Test + void writesTheManifestUnderTheGivenEntryName() throws IOException { + var out = new ByteArrayOutputStream(); + var writer = new TDFWriter(out, TDFWriter.TDF_MANIFEST_FILE_NAME_SPEC); + 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"); + } + } }