Skip to content
Draft
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
1 change: 1 addition & 0 deletions .github/workflows/fuzz.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ jobs:
target:
- fuzzZipRead
- fuzzTDF
- fuzzIsValidTdf
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
Expand Down
43 changes: 41 additions & 2 deletions cmdline/src/main/java/io/opentdf/platform/Command.java
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ class Versions {
}

@CommandLine.Command(name = "tdf", subcommands = { HelpCommand.class,
Command.Supports.class }, version = "{\"version\":\"" + Versions.SDK
Command.Supports.class, Command.Validate.class }, version = "{\"version\":\"" + Versions.SDK
+ "\",\"tdfSpecVersion\":\"" + Versions.TDF_SPEC + "\"}")
class Command {
@Option(names = { "-V", "--version" }, versionHelp = true, description = "display version info")
Expand All @@ -77,7 +77,7 @@ class Command {
static class Supports implements Callable<Integer> {
// Static, always-on capabilities of this build. There is no "known but
// unsupported" feature here: anything not in this list is simply unrecognized.
private static final List<String> FEATURES = List.of("dpop", "dpop_nonce_challenge");
private static final List<String> FEATURES = List.of("dpop", "dpop_nonce_challenge", "tdf_validity");

@CommandLine.Parameters(index = "0", arity = "0..1", description = "Feature to check (e.g., dpop). Omit to list all supported features.")
private String feature;
Expand Down Expand Up @@ -114,6 +114,45 @@ private void printFeatures(List<String> features) {
}
}

/**
* {@code tdf validate <file>}: {@link SDK#validateTdf} from the command line. The output and
* exit codes are shared with the Go and JavaScript CLIs, for the cross-SDK validity suite in
* opentdf/tests.
*/
@CommandLine.Command(name = "validate",
description = { "Check whether a file is a valid TDF. Needs no platform connection or credentials.",
"Prints one line of JSON: {\"valid\":true}, or {\"valid\":false,\"reason\":\"...\"}." },
exitCodeListHeading = "Exit codes:%n",
exitCodeList = { "0:The file is a valid TDF.", "3:The file is not a valid TDF.",
"1:The check could not run, for example because the file could not be read.",
"2:The command line was not valid." })
static class Validate implements Callable<Integer> {
/** The exit code for a file that is not a valid TDF; picocli already uses 1 and 2 for errors. */
static final int INVALID = 3;

private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create();

@CommandLine.Parameters(index = "0", paramLabel = "<file>", description = "The file to check")
private Path file;

@Override
public Integer call() throws IOException {
Map<String, Object> result = new LinkedHashMap<>();
int exitCode;
try (var tdf = FileChannel.open(file, StandardOpenOption.READ)) {
SDK.validateTdf(tdf);
result.put("valid", true);
exitCode = CommandLine.ExitCode.OK;
} catch (SDK.MalformedTDFException e) {
result.put("valid", false);
result.put("reason", e.getMessage());
exitCode = INVALID;
}
System.out.println(GSON.toJson(result));
return exitCode;
}
}

private static class AssertionKeyDeserializer implements JsonDeserializer<AssertionConfig.AssertionKey> {
@Override
public AssertionConfig.AssertionKey deserialize(JsonElement json, java.lang.reflect.Type typeOfT,
Expand Down
124 changes: 122 additions & 2 deletions cmdline/src/test/java/io/opentdf/platform/CommandTest.java
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
package io.opentdf.platform;

import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.opentdf.platform.sdk.ZipWriter;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import picocli.CommandLine;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.PrintStream;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

import static org.assertj.core.api.Assertions.assertThat;

Expand Down Expand Up @@ -95,7 +102,7 @@ void supports_noArgs_listsFeatures_exits0() {
String out = captureStdout(() -> code[0] = new CommandLine(new Command()).execute("supports"));

assertThat(code[0]).isEqualTo(0);
assertThat(out.lines()).containsExactlyInAnyOrder("dpop", "dpop_nonce_challenge");
assertThat(out.lines()).containsExactlyInAnyOrder("dpop", "dpop_nonce_challenge", "tdf_validity");
}

@Test
Expand All @@ -104,7 +111,7 @@ void supports_noArgs_json_listsFeatures() {
String out = captureStdout(() -> code[0] = new CommandLine(new Command()).execute("supports", "--json"));

assertThat(code[0]).isEqualTo(0);
assertThat(out.trim()).isEqualTo("{\"dpop\":true,\"dpop_nonce_challenge\":true}");
assertThat(out.trim()).isEqualTo("{\"dpop\":true,\"dpop_nonce_challenge\":true,\"tdf_validity\":true}");
}

@Test
Expand Down Expand Up @@ -137,4 +144,117 @@ void supports_unknownFeature_json_false() {
assertThat(out.trim()).isEqualTo("{\"unknown_feature\":false}");
}

@Test
void supports_tdf_validity_exits_0() {
int code = new CommandLine(new Command()).execute("supports", "tdf_validity");
assertThat(code).isEqualTo(0);
}

private static final String MINIMAL_MANIFEST = "{\"payload\":{\"type\":\"reference\",\"url\":\"0.payload\","
+ "\"protocol\":\"zip\",\"isEncrypted\":true},\"encryptionInformation\":{}}";

@TempDir
Path tempDir;

private Path tdfWithManifest(String manifest) throws IOException {
var out = new ByteArrayOutputStream();
var zip = new ZipWriter(out);
zip.data("0.payload", new byte[16]);
zip.data("0.manifest.json", manifest.getBytes(StandardCharsets.UTF_8));
zip.finish();
return Files.write(Files.createTempFile(tempDir, "validate", ".tdf"), out.toByteArray());
}

/** Runs {@code validate} the way the jar does, without platform options, and returns stdout. */
private String validate(int[] code, String... args) {
String[] command = new String[args.length + 1];
command[0] = "validate";
System.arraycopy(args, 0, command, 1, args.length);
return captureStdout(() -> code[0] = new CommandLine(new Command()).execute(command));
}

@Test
void validate_validTdf_printsValidAndExits0() throws IOException {
int[] code = new int[1];
String out = validate(code, tdfWithManifest(MINIMAL_MANIFEST).toString());

assertThat(code[0]).isEqualTo(0);
assertThat(out).isEqualTo("{\"valid\":true}" + System.lineSeparator());
}

@Test
void validate_invalidManifest_printsReasonAndExits3() throws IOException {
int[] code = new int[1];
String out = validate(code, tdfWithManifest("{\"encryptionInformation\":{}}").toString());

assertThat(code[0]).isEqualTo(3);
assertThat(out.lines()).hasSize(1);
JsonObject result = JsonParser.parseString(out).getAsJsonObject();
assertThat(result.keySet()).containsExactly("valid", "reason");
assertThat(result.get("valid").getAsBoolean()).isFalse();
assertThat(result.get("reason").getAsString()).contains("lax manifest schema");
}

@Test
void validate_fileThatIsNotAZip_exits3() throws IOException {
int[] code = new int[1];
Path notAZip = Files.writeString(tempDir.resolve("not-a-zip"), MINIMAL_MANIFEST);
String out = validate(code, notAZip.toString());

assertThat(code[0]).isEqualTo(3);
assertThat(JsonParser.parseString(out).getAsJsonObject().get("valid").getAsBoolean()).isFalse();
}

@Test
void validate_emptyFile_exits3() throws IOException {
int[] code = new int[1];
String out = validate(code, Files.createFile(tempDir.resolve("empty")).toString());

assertThat(code[0]).isEqualTo(3);
assertThat(JsonParser.parseString(out).getAsJsonObject().get("valid").getAsBoolean()).isFalse();
}

/** A reason with quotes, angle brackets and the like is still one line of JSON. */
@Test
void validate_reasonIsEscapedJson() throws IOException {
int[] code = new int[1];
String out = validate(code, tdfWithManifest("{\"<'quoted'>\": \"\\u0022\"").toString());

assertThat(code[0]).isEqualTo(3);
assertThat(out.lines()).hasSize(1);
assertThat(JsonParser.parseString(out).getAsJsonObject().get("reason").getAsString())
.startsWith("manifest is not valid JSON");
}

/** A file that can't be read is not an invalid TDF: the check didn't run. */
@Test
void validate_missingFile_exitsWithAnotherCodeAndPrintsNothing() {
int[] code = new int[1];
StringWriter err = new StringWriter();
String out = captureStdout(() -> {
CommandLine cli = new CommandLine(new Command());
cli.setErr(new PrintWriter(err));
code[0] = cli.execute("validate", tempDir.resolve("does-not-exist.tdf").toString());
});

assertThat(code[0]).isNotIn(0, 3);
assertThat(code[0]).isEqualTo(CommandLine.ExitCode.SOFTWARE);
assertThat(out).isEmpty();
}

@Test
void validate_withoutAFile_isAUsageError() {
int[] code = new int[1];
StringWriter err = new StringWriter();
String out = captureStdout(() -> {
CommandLine cli = new CommandLine(new Command());
cli.setErr(new PrintWriter(err));
code[0] = cli.execute("validate");
});

assertThat(code[0]).isEqualTo(CommandLine.ExitCode.USAGE);
assertThat(out).isEmpty();
assertThat(err.toString()).contains("Missing required parameter: '<file>'");
}

}
7 changes: 7 additions & 0 deletions sdk/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@
<artifactId>java-json-canonicalization</artifactId>
<version>1.1</version>
</dependency>
<!-- JSON Schema validation of TDF manifests (SDK.isValidTdf). MIT licensed, no required
dependencies; it validates the Gson trees this SDK already parses -->
<dependency>
<groupId>dev.harrel</groupId>
<artifactId>json-schema</artifactId>
<version>1.9.1</version>
</dependency>
<dependency>
<groupId>build.buf</groupId>
<artifactId>protovalidate</artifactId>
Expand Down
60 changes: 60 additions & 0 deletions sdk/src/main/java/io/opentdf/platform/sdk/SDK.java
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,12 @@ public Optional<SrtSigner> getSrtSigner() {
* An archive that lists one name twice is rejected, again matching {@link TDFReader}:
* passing it here and then failing the read would hand callers an exception from the
* very check this method exists to spare them.
* <p>
* This looks only at the entry names in the central directory. It never reads the manifest,
* so an archive whose manifest is missing fields, or is not JSON at all, still passes. To
* check the manifest too, use {@link #isValidTdf}, which costs a read and parse of the
* manifest. Unlike that method, this one never throws: a channel that can't be read is
* reported as not a TDF.
*
* @param channel A channel containing the bytes of the potential Z-TDF
* @return `true` if
Expand All @@ -192,6 +198,60 @@ public static boolean isTDF(SeekableByteChannel channel) {
&& names.contains(TDFWriter.TDF_PAYLOAD_FILE_NAME);
}

/**
* Checks whether the channel holds a valid TDF: an archive that {@link #readManifest} and
* {@link #loadTDF} can open, whose manifest is JSON that satisfies the lax manifest schema.
* This asks the same question as the Go SDK's {@code IsValidTdf} and the web SDK's
* {@code isValidTdf}, against the same schema. No platform connection or credentials are
* needed.
* <p>
* A TDF is valid if all of these hold:
* <ul>
* <li>It is a zip archive that lists no entry name twice and has a {@code 0.payload}
* entry.</li>
* <li>It has a manifest entry. The spec name {@code manifest.json} is used if present, and
* otherwise {@code 0.manifest.json}, the name this SDK writes. When {@code manifest.json}
* is present but invalid, {@code 0.manifest.json} is not tried.</li>
* <li>The manifest is exactly one RFC 8259 JSON value, in UTF-8, with no byte order mark
* and nothing after the value, nested at most 10,000 deep.</li>
* <li>The manifest satisfies {@code manifest-lax.schema.json} from the Go SDK v0.35.0.</li>
* </ul>
* Nothing is decrypted or verified: not the payload, the integrity information, the policy
* binding or the assertion signatures. The lax schema leaves out fields that decryption
* needs, so {@code true} does not promise that {@link #loadTDF} succeeds.
* <p>
* The manifest is parsed into memory and checked there. A typical manifest takes about 20 times
* its size in the file (about 2 KB per segment), and one built to inflate can take over 100
* times its size. Callers that check untrusted files should limit how large a file they accept.
*
* @param channel the bytes to check. They are read from wherever the archive structure
* points, and the channel's position is not restored
* @return {@code true} if the channel holds a valid TDF, {@code false} if the archive or its
* manifest is malformed
* @throws IOException if the channel can't be read
* @see #validateTdf(SeekableByteChannel) validateTdf, to find out why a TDF is not valid
*/
public static boolean isValidTdf(SeekableByteChannel channel) throws IOException {
try {
validateTdf(channel);
return true;
} catch (MalformedTDFException e) {
return false;
}
}

/**
* Makes the same check as {@link #isValidTdf}, and reports why a TDF is not valid.
*
* @param channel the bytes to check
* @throws MalformedTDFException if the channel does not hold a valid TDF. The message names
* the first problem found, on a single line.
* @throws IOException if the channel can't be read
*/
public static void validateTdf(SeekableByteChannel channel) throws IOException {
TDFValidator.validate(channel);
}

/**
* Reads the {@link Manifest} without decrypting the TDF
* @param tdfBytes A SeekableByteChannel containing the TDF data
Expand Down
Loading
Loading