Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion cmdline/src/main/java/io/opentdf/platform/Command.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<KeyType> encapKeyType,
@Option(names = { "--mime-type" }, defaultValue = Option.NULL_VALUE) Optional<String> mimeType,
@Option(names = { "--with-assertions" }, defaultValue = Option.NULL_VALUE) Optional<String> assertion,
@Option(names = { "--with-target-mode" }, defaultValue = Option.NULL_VALUE) Optional<String> targetMode)
@Option(names = { "--with-target-mode" }, defaultValue = Option.NULL_VALUE) Optional<String> targetMode,
@Option(names = { "--unprefixed-manifest-name" }, description = "Use the spec manifest entry name (manifest.json)") boolean unprefixedManifestName)

throws IOException, AutoConfigureException {

Expand Down Expand Up @@ -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)) {
Expand Down
13 changes: 13 additions & 0 deletions cmdline/src/test/java/io/opentdf/platform/CommandTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,19 @@ void encrypt_withoutCredentials_failsWithMissingPlatformEndpoint() {
assertThat(err.toString()).contains("Missing required option: '--platform-endpoint=<platformEndpoint>'");
}

@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=<platformEndpoint>'");
}

@Test
void supports_withoutCredentials_stillExits0() {
// Regression sentinel: tdf supports must not require --client-id/--client-secret/--platform-endpoint.
Expand Down
12 changes: 12 additions & 0 deletions sdk/src/main/java/io/opentdf/platform/sdk/Config.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -211,6 +212,7 @@ public TDFConfig() {
this.hexEncodeRootAndSegmentHashes = false;
this.renderVersionInfoInManifest = true;
this.systemMetadataAssertion = false;
this.unprefixedManifestName = false;
}
}

Expand Down Expand Up @@ -325,6 +327,16 @@ public static Consumer<TDFConfig> 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<TDFConfig> withUnprefixedManifestName() {
return (TDFConfig config) -> config.unprefixedManifestName = true;
}

public static Consumer<TDFConfig> WithWrappingKeyAlg(KeyType keyType) {
return (TDFConfig config) -> config.wrappingKeyType = keyType;
}
Expand Down
5 changes: 4 additions & 1 deletion sdk/src/main/java/io/opentdf/platform/sdk/TDF.java
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
24 changes: 15 additions & 9 deletions sdk/src/main/java/io/opentdf/platform/sdk/TDFWriter.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,37 +12,43 @@ 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 <a href="https://github.com/opentdf/platform/issues/3513">platform#3513</a>.
* 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 <a href="https://github.com/opentdf/platform/issues/3513">platform#3513</a>.
*/
public static final String TDF_MANIFEST_FILE_NAME = "0.manifest.json";

/**
* The manifest entry name given by the OpenTDF spec:
* <a href="https://opentdf.io/spec#tdf-structure">opentdf.io/spec</a>. Read-side only --
* {@link TDFReader} accepts it so archives from spec-conformant implementations can be
* read, and the writer does not yet emit it.
* <a href="https://opentdf.io/spec#tdf-structure">opentdf.io/spec</a>.
* {@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;
}

/**
* Test seam. See {@link ZipWriter#ZipWriter(OutputStream, long)}.
*/
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 {
Expand Down
6 changes: 6 additions & 0 deletions sdk/src/test/java/io/opentdf/platform/sdk/ConfigTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
91 changes: 91 additions & 0 deletions sdk/src/test/java/io/opentdf/platform/sdk/TDFTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<Config.TDFConfig>[] optionsFirstOrder = new Consumer[]{
Config.withAutoconfigure(false),
Config.withKasInformation(getSingleRSAKASInfo()),
Config.withUnprefixedManifestName(),
Config.withTargetMode("4.2.2")
};
Consumer<Config.TDFConfig>[] optionsSecondOrder = new Consumer[]{
Config.withAutoconfigure(false),
Config.withKasInformation(getSingleRSAKASInfo()),
Config.withTargetMode("4.2.2"),
Config.withUnprefixedManifestName()
};

for (Consumer<Config.TDFConfig>[] 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<Integer> filter) {
var kasInfos = new ArrayList<Config.KASInfo>();
Expand Down
20 changes: 20 additions & 0 deletions sdk/src/test/java/io/opentdf/platform/sdk/TDFWriterTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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");
}
}
}
Loading