Make configuration and compatibility contracts executable

This commit is contained in:
2026-11-12 08:27:53 +01:00
parent 306266ae40
commit 85a75383a0
28 changed files with 2284 additions and 8 deletions
@@ -0,0 +1,45 @@
package de.neitzel.fxdesigner.config;
import java.util.*;
/** Versioned shared configuration. Validated and resolved by core before use. */
public record DesignerConfiguration(String schemaVersion, Map<String, Context> contexts) {
public DesignerConfiguration {
contexts = Map.copyOf(contexts);
}
public record Preview(
String sourceSet,
String module,
String provider,
String defaultExample,
List<String> examples) {
public Preview {
examples = List.copyOf(examples);
}
}
public record ModulePath(String entryModule, List<String> addReads, List<String> addOpens) {
public ModulePath {
addReads = List.copyOf(addReads);
addOpens = List.copyOf(addOpens);
}
}
public record Context(
String applicationModule,
List<String> mavenProfiles,
String launchMode,
String buildRuntime,
String targetRuntime,
int buildJava,
int targetJava,
Set<String> requiredCapabilities,
Preview preview,
ModulePath modulePath) {
public Context {
mavenProfiles = List.copyOf(mavenProfiles);
requiredCapabilities = Set.copyOf(requiredCapabilities);
}
}
}
@@ -0,0 +1,182 @@
package de.neitzel.fxdesigner.protocol;
import static de.neitzel.fxdesigner.protocol.WireProtocol.*;
import java.io.*;
import java.nio.*;
import java.nio.charset.*;
import java.util.*;
/**
* Network byte order length-prefix, fixed magic, strict bounded fields. No object deserialization.
*/
public final class FrameCodec {
private static final int MAGIC = 0x4e465831;
private FrameCodec() {}
public static void write(OutputStream stream, Message message) throws IOException {
var bytes = new ByteArrayOutputStream();
var out = new DataOutputStream(bytes);
out.writeInt(MAGIC);
if (message instanceof Hello h) {
out.writeByte(0);
text(out, h.session());
out.writeLong(h.workerPid());
text(out, h.token());
version(out, h.wire());
version(out, h.metadata());
version(out, h.preview());
strings(out, h.capabilities());
strings(out, h.required());
} else if (message instanceof Welcome w) {
out.writeByte(2);
text(out, w.session());
version(out, w.wire());
var names = new HashSet<String>();
w.operations().forEach(o -> names.add(o.name()));
strings(out, names);
} else if (message instanceof Packet p) {
out.writeByte(1);
out.writeByte(p.kind().ordinal());
out.writeByte(p.operation().ordinal());
var i = p.identity();
text(out, i.session());
text(out, i.request());
text(out, i.context());
text(out, i.snapshot());
out.writeInt(i.revisions().size());
for (var e : new TreeMap<>(i.revisions()).entrySet()) {
text(out, e.getKey());
out.writeLong(e.getValue());
}
out.writeInt(i.width());
out.writeInt(i.height());
byte[] data = p.payload();
out.writeInt(data.length);
out.write(data);
}
if (bytes.size() > MAX_FRAME) throw new IOException("Frame exceeds limit");
var target = new DataOutputStream(stream);
target.writeInt(bytes.size());
bytes.writeTo(target);
target.flush();
}
/** EOF/truncation is a transport failure. Length is checked before allocating the frame. */
public static Message read(InputStream stream) throws IOException {
var source = new DataInputStream(stream);
int size = source.readInt();
if (size < 5 || size > MAX_FRAME) throw new IOException("Invalid frame length");
byte[] data = source.readNBytes(size);
if (data.length != size) throw new EOFException("Truncated frame");
var in = new DataInputStream(new ByteArrayInputStream(data));
try {
if (in.readInt() != MAGIC) throw new IOException("Invalid protocol magic");
Message message;
int type = in.readUnsignedByte();
if (type == 0)
message =
new Hello(
text(in),
in.readLong(),
text(in),
version(in),
version(in),
version(in),
strings(in),
strings(in));
else if (type == 1) {
int k = in.readUnsignedByte(), o = in.readUnsignedByte();
if (k >= Kind.values().length || o >= Operation.values().length)
throw new IOException("Unknown message kind/operation");
String session = text(in), request = text(in), context = text(in), snapshot = text(in);
int count = count(in, MAX_DOCUMENTS);
var revisions = new LinkedHashMap<String, Long>();
for (int n = 0; n < count; n++) {
String key = text(in);
if (revisions.put(key, in.readLong()) != null)
throw new IOException("Duplicate document revision");
}
var identity =
new Identity(
session, request, context, snapshot, revisions, in.readInt(), in.readInt());
int length =
count(
in,
o == Operation.RENDER.ordinal() && k == Kind.RESULT.ordinal()
? MAX_IMAGE
: MAX_METADATA);
if (o == Operation.RENDER.ordinal()
&& k == Kind.RESULT.ordinal()
&& length != (long) identity.width() * identity.height() * 4)
throw new IOException("Image dimensions do not match payload");
if (length > in.available()) throw new EOFException("Truncated payload");
message =
new Packet(Kind.values()[k], Operation.values()[o], identity, in.readNBytes(length));
} else if (type == 2) {
String session = text(in);
var wire = version(in);
var operations = EnumSet.noneOf(Operation.class);
for (String operation : strings(in)) operations.add(Operation.valueOf(operation));
message = new Welcome(session, wire, operations);
} else throw new IOException("Unknown frame type");
if (in.available() != 0) throw new IOException("Trailing frame content");
return message;
} catch (IllegalArgumentException e) {
throw new IOException("Invalid protocol fields", e);
}
}
private static int count(DataInputStream in, int maximum) throws IOException {
int n = in.readInt();
if (n < 0 || n > maximum) throw new IOException("Field exceeds limit");
return n;
}
private static void text(DataOutputStream out, String value) throws IOException {
if (value.length() > MAX_TEXT) throw new IOException("Text exceeds limit");
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_TEXT) throw new IOException("Text exceeds limit");
out.writeInt(bytes.length);
out.write(bytes);
}
private static String text(DataInputStream in) throws IOException {
int n = count(in, MAX_TEXT);
byte[] b = in.readNBytes(n);
if (b.length != n) throw new EOFException("Truncated text");
try {
return StandardCharsets.UTF_8
.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(b))
.toString();
} catch (CharacterCodingException e) {
throw new IOException("Invalid UTF-8", e);
}
}
private static void version(DataOutputStream out, Version v) throws IOException {
out.writeInt(v.major());
out.writeInt(v.minor());
}
private static Version version(DataInputStream in) throws IOException {
return new Version(in.readInt(), in.readInt());
}
private static void strings(DataOutputStream out, Set<String> values) throws IOException {
if (values.size() > 128) throw new IOException("Too many capabilities");
out.writeInt(values.size());
for (String v : new TreeSet<>(values)) text(out, v);
}
private static Set<String> strings(DataInputStream in) throws IOException {
int count = count(in, 128);
var s = new HashSet<String>();
for (int n = 0; n < count; n++)
if (!s.add(text(in))) throw new IOException("Duplicate capability");
return s;
}
}
@@ -0,0 +1,167 @@
package de.neitzel.fxdesigner.protocol;
import static de.neitzel.fxdesigner.protocol.WireProtocol.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.*;
import java.util.*;
/**
* Host-side authentication/negotiation and bounded request correlation, independent of sockets/UI.
*/
public final class ProtocolSession implements AutoCloseable {
private enum State {
NEW,
READY,
CLOSED
}
private State state = State.NEW;
private final String session, token;
private final long workerPid;
private final Set<String> supported, required;
private Set<Operation> operations = Set.of();
private record Pending(Packet request, Instant deadline) {}
private final Map<String, Pending> pending = new HashMap<>();
private long sequence;
public ProtocolSession(
String session, long workerPid, String token, Set<String> supported, Set<String> required) {
if (session == null
|| session.isBlank()
|| workerPid < 1
|| token == null
|| !token.matches("[0-9a-f]{64}"))
throw new IllegalArgumentException("Invalid launch identity");
this.session = session;
this.workerPid = workerPid;
this.token = token;
this.supported = Set.copyOf(supported);
this.required = Set.copyOf(required);
}
public static String newToken() {
byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
return HexFormat.of().formatHex(bytes);
}
public synchronized Set<Operation> authenticate(InetAddress peer, Hello hello) {
if (state != State.NEW) throw new IllegalStateException("Handshake already consumed");
state = State.CLOSED;
if (!peer.isLoopbackAddress()
|| !session.equals(hello.session())
|| workerPid != hello.workerPid()
|| !MessageDigest.isEqual(
token.getBytes(StandardCharsets.US_ASCII),
hello.token().getBytes(StandardCharsets.UTF_8)))
throw new IllegalArgumentException("Worker authentication failed");
if (hello.wire().major() != 1)
throw new IllegalArgumentException("Unsupported wire major version");
if (!supported.containsAll(hello.required()) || !hello.capabilities().containsAll(required))
throw new IllegalArgumentException("Required capability unavailable");
var usable = EnumSet.noneOf(Operation.class);
if (hello.metadata().major() == 1
&& supported.contains("discover-v1")
&& hello.capabilities().contains("discover-v1")) usable.add(Operation.DISCOVER);
if (hello.preview().major() == 1
&& supported.contains("render-rgba-v1")
&& hello.capabilities().contains("render-rgba-v1")) usable.add(Operation.RENDER);
var requiredByEither = new HashSet<>(required);
requiredByEither.addAll(hello.required());
if ((requiredByEither.contains("discover-v1") && !usable.contains(Operation.DISCOVER))
|| (requiredByEither.contains("render-rgba-v1") && !usable.contains(Operation.RENDER)))
throw new IllegalArgumentException("Required capability version incompatible");
operations = Set.copyOf(usable);
state = State.READY;
return operations;
}
public synchronized Welcome welcome() {
ready();
return new Welcome(session, new Version(1, 0), operations);
}
public synchronized Packet request(
Operation operation,
String context,
String snapshot,
Map<String, Long> revisions,
int width,
int height,
byte[] payload,
Instant now,
Duration timeout) {
ready();
if (!operations.contains(operation))
throw new IllegalStateException("Operation not negotiated");
if (timeout.isNegative() || timeout.isZero() || timeout.compareTo(Duration.ofMinutes(5)) > 0)
throw new IllegalArgumentException("Invalid request timeout");
expire(now);
if (pending.size() >= MAX_PENDING) throw new IllegalStateException("Too many pending requests");
var packet =
new Packet(
Kind.REQUEST,
operation,
new Identity(
session, Long.toString(++sequence), context, snapshot, revisions, width, height),
payload);
pending.put(packet.identity().request(), new Pending(packet, now.plus(timeout)));
return packet;
}
/** False means stale/unrelated/mismatched; such results must not mutate the UI. */
public synchronized boolean accept(Packet result, Instant now) {
ready();
expire(now);
if (result.kind() != Kind.RESULT && result.kind() != Kind.ERROR) return false;
var p = pending.get(result.identity().request());
if (p == null
|| p.request.operation() != result.operation()
|| !p.request.identity().equals(result.identity())) return false;
pending.remove(result.identity().request());
return true;
}
public synchronized Optional<Packet> cancel(String request) {
ready();
var p = pending.remove(request);
return p == null
? Optional.empty()
: Optional.of(
new Packet(Kind.CANCEL, p.request.operation(), p.request.identity(), new byte[0]));
}
/**
* On any render input/size/snapshot change the caller cancels the superseded request before
* issuing another.
*/
public synchronized List<Packet> expire(Instant now) {
var cancelled = new ArrayList<Packet>();
var iterator = pending.values().iterator();
while (iterator.hasNext()) {
var p = iterator.next();
if (!now.isBefore(p.deadline)) {
cancelled.add(
new Packet(Kind.CANCEL, p.request.operation(), p.request.identity(), new byte[0]));
iterator.remove();
}
}
return List.copyOf(cancelled);
}
private void ready() {
if (state != State.READY)
throw new IllegalStateException("Session not authenticated or closed");
}
public synchronized void close() {
state = State.CLOSED;
pending.clear();
}
}
@@ -0,0 +1,153 @@
package de.neitzel.fxdesigner.protocol;
import java.util.*;
/** Draft wire 1.0 types. Product, metadata and preview versions are independent. */
public final class WireProtocol {
private WireProtocol() {}
public static final int MAX_TEXT = 4096,
MAX_METADATA = 1024 * 1024,
MAX_IMAGE = 16 * 1024 * 1024,
MAX_FRAME = MAX_IMAGE + MAX_METADATA,
MAX_DOCUMENTS = 128,
MAX_PENDING = 128;
public record Version(int major, int minor) {
public Version {
if (major < 0 || minor < 0) throw new IllegalArgumentException("Negative version");
}
}
public sealed interface Message permits Hello, Welcome, Packet {}
public record Hello(
String session,
long workerPid,
String token,
Version wire,
Version metadata,
Version preview,
Set<String> capabilities,
Set<String> required)
implements Message {
public Hello {
Objects.requireNonNull(session);
Objects.requireNonNull(token);
Objects.requireNonNull(wire);
Objects.requireNonNull(metadata);
Objects.requireNonNull(preview);
capabilities = Set.copyOf(capabilities);
required = Set.copyOf(required);
}
@Override
public String toString() {
return "Hello[session=" + session + ", workerPid=" + workerPid + ", credentials=<redacted>]";
}
}
public record Welcome(String session, Version wire, Set<Operation> operations)
implements Message {
public Welcome {
Objects.requireNonNull(session);
Objects.requireNonNull(wire);
operations = Set.copyOf(operations);
}
}
public enum Kind {
REQUEST,
RESULT,
ERROR,
CANCEL
}
public enum ErrorCode {
INVALID_REQUEST,
LOAD_FAILED,
TIMEOUT,
CANCELLED,
INTERNAL_ERROR
}
public enum Operation {
DISCOVER,
RENDER
}
public record Identity(
String session,
String request,
String context,
String snapshot,
Map<String, Long> revisions,
int width,
int height) {
public Identity {
for (String s : List.of(session, request, context, snapshot))
if (s.isBlank() || s.length() > MAX_TEXT)
throw new IllegalArgumentException("Invalid correlation identity");
revisions = Map.copyOf(revisions);
if (revisions.size() > MAX_DOCUMENTS || revisions.values().stream().anyMatch(r -> r < 0))
throw new IllegalArgumentException("Invalid revisions");
if (width < 0
|| height < 0
|| width > 8192
|| height > 8192
|| (long) width * height * 4 > MAX_IMAGE)
throw new IllegalArgumentException("Invalid image dimensions");
}
}
/**
* RENDER RESULT payloads are raw RGBA8; other payloads are opaque UTF-8 metadata owned by later
* adapters.
*/
public record Packet(Kind kind, Operation operation, Identity identity, byte[] payload)
implements Message {
public Packet {
Objects.requireNonNull(kind);
Objects.requireNonNull(operation);
Objects.requireNonNull(identity);
Objects.requireNonNull(payload);
boolean render = operation == Operation.RENDER;
if (render
&& (identity.width() == 0 || identity.height() == 0 || identity.revisions().isEmpty()))
throw new IllegalArgumentException("Rendering needs dimensions and document revisions");
if (!render
&& (identity.width() != 0 || identity.height() != 0 || !identity.revisions().isEmpty()))
throw new IllegalArgumentException("Discovery does not carry render identity");
boolean image = render && kind == Kind.RESULT;
if (image
? payload.length != (long) identity.width() * identity.height() * 4
: payload.length > MAX_METADATA)
throw new IllegalArgumentException("Invalid payload size");
if (kind == Kind.CANCEL && payload.length != 0)
throw new IllegalArgumentException("Cancellation payload must be empty");
if (!image) {
try {
String text =
java.nio.charset.StandardCharsets.UTF_8
.newDecoder()
.onMalformedInput(java.nio.charset.CodingErrorAction.REPORT)
.decode(java.nio.ByteBuffer.wrap(payload))
.toString();
if (kind == Kind.ERROR) {
int split = text.indexOf('\n');
if (split < 1) throw new IllegalArgumentException("Error needs code and detail");
ErrorCode.valueOf(text.substring(0, split));
}
} catch (java.nio.charset.CharacterCodingException e) {
throw new IllegalArgumentException("Invalid UTF-8 payload", e);
}
}
payload = payload.clone();
}
@Override
public byte[] payload() {
return payload.clone();
}
}
}
@@ -0,0 +1,210 @@
{
"type": "object",
"properties": {
"schemaVersion": {
"const": "1.0"
},
"contexts": {
"type": "object",
"minProperties": 1,
"maxProperties": 128,
"propertyNames": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_.-]*$",
"maxLength": 128
},
"additionalProperties": {
"type": "object",
"properties": {
"applicationModule": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"mavenProfiles": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"uniqueItems": true,
"maxItems": 128
},
"launchMode": {
"enum": [
"classpath",
"module-path"
]
},
"buildRuntime": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_.-]*$",
"maxLength": 128
},
"targetRuntime": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_.-]*$",
"maxLength": 128
},
"buildJava": {
"type": "integer",
"minimum": 21,
"maximum": 99
},
"targetJava": {
"type": "integer",
"minimum": 21,
"maximum": 99
},
"requiredCapabilities": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"uniqueItems": true,
"maxItems": 128
},
"preview": {
"type": "object",
"properties": {
"sourceSet": {
"enum": [
"module",
"test"
]
},
"module": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"provider": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"defaultExample": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"examples": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"uniqueItems": true,
"maxItems": 128
}
},
"required": [
"sourceSet",
"provider"
],
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": {
"sourceSet": {
"const": "module"
}
}
},
"then": {
"required": [
"module"
]
},
"else": {
"not": {
"required": [
"module"
]
}
}
}
]
},
"modulePath": {
"type": "object",
"properties": {
"entryModule": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_.-]*$",
"maxLength": 128
},
"addReads": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"uniqueItems": true,
"maxItems": 128
},
"addOpens": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"uniqueItems": true,
"maxItems": 128
}
},
"required": [
"entryModule"
],
"additionalProperties": false
}
},
"required": [
"applicationModule",
"launchMode",
"buildRuntime",
"targetRuntime",
"preview"
],
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": {
"launchMode": {
"const": "module-path"
}
}
},
"then": {
"required": [
"modulePath"
]
},
"else": {
"not": {
"required": [
"modulePath"
]
}
}
}
]
}
}
},
"required": [
"schemaVersion",
"contexts"
],
"additionalProperties": false,
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "urn:neitzelfxdesigner:configuration:1.0"
}
@@ -0,0 +1,50 @@
{
"type": "object",
"properties": {
"schemaVersion": {
"const": "1.0"
},
"runtimes": {
"type": "object",
"maxProperties": 128,
"propertyNames": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_.-]*$",
"maxLength": 128
},
"additionalProperties": {
"type": "object",
"properties": {
"build": {
"type": "string",
"minLength": 1,
"maxLength": 512
},
"target": {
"type": "string",
"minLength": 1,
"maxLength": 512
}
},
"required": [],
"additionalProperties": false
}
},
"rememberedContexts": {
"type": "object",
"additionalProperties": {
"type": "string",
"pattern": "^[A-Za-z_][A-Za-z0-9_.-]*$",
"maxLength": 128
},
"maxProperties": 1024
}
},
"required": [
"schemaVersion",
"runtimes"
],
"additionalProperties": false,
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "urn:neitzelfxdesigner:local-settings:1.0"
}
@@ -0,0 +1,381 @@
package de.neitzel.fxdesigner.protocol;
import static de.neitzel.fxdesigner.protocol.WireProtocol.*;
import static org.junit.jupiter.api.Assertions.*;
import java.io.*;
import java.net.*;
import java.time.*;
import java.util.*;
import org.junit.jupiter.api.Test;
class ProtocolTest {
private static final String TOKEN = "a".repeat(64);
private static final Set<String> CAPS = Set.of("discover-v1", "render-rgba-v1");
private static final Instant NOW = Instant.parse("2026-11-12T00:00:00Z");
private Hello hello() {
return new Hello(
"session",
42,
TOKEN,
new Version(1, 0),
new Version(1, 0),
new Version(1, 0),
CAPS,
Set.of());
}
private ProtocolSession session() throws Exception {
var s = new ProtocolSession("session", 42, TOKEN, CAPS, Set.of());
s.authenticate(InetAddress.getLoopbackAddress(), hello());
return s;
}
private Packet render(ProtocolSession s) throws Exception {
return s.request(
Operation.RENDER,
"desktop",
"sha256-build",
Map.of("View.fxml", 3L),
2,
2,
new byte[0],
NOW,
Duration.ofSeconds(5));
}
private Packet response(Packet request) {
return new Packet(
Kind.RESULT,
request.operation(),
request.identity(),
request.operation() == Operation.RENDER ? new byte[16] : "{}".getBytes());
}
@Test
void fixedWireFixtureMatchesDocumentedNetworkByteOrder() throws Exception {
byte[] fixture;
try (var in = getClass().getResourceAsStream("/protocol/discovery-request.hex")) {
fixture =
HexFormat.of()
.parseHex(
new String(in.readAllBytes(), java.nio.charset.StandardCharsets.US_ASCII)
.strip());
}
var packet = (Packet) FrameCodec.read(new ByteArrayInputStream(fixture));
assertEquals(new Identity("s", "1", "c", "b", Map.of(), 0, 0), packet.identity());
assertEquals(Kind.REQUEST, packet.kind());
assertEquals(Operation.DISCOVER, packet.operation());
assertArrayEquals("{}".getBytes(), packet.payload());
var out = new ByteArrayOutputStream();
FrameCodec.write(out, packet);
assertArrayEquals(fixture, out.toByteArray());
}
@Test
void welcomeIsExplicitAndContainsNoCredentials() throws Exception {
var welcome = session().welcome();
var out = new ByteArrayOutputStream();
FrameCodec.write(out, welcome);
assertEquals(welcome, FrameCodec.read(new ByteArrayInputStream(out.toByteArray())));
assertEquals(Set.of(Operation.DISCOVER, Operation.RENDER), welcome.operations());
assertFalse(welcome.toString().contains(TOKEN));
}
@Test
void exactPayloadLimitsAreUsable() throws Exception {
var image =
new Packet(
Kind.RESULT,
Operation.RENDER,
new Identity("s", "r", "c", "b", Map.of("View.fxml", 0L), 2048, 2048),
new byte[MAX_IMAGE]);
var out = new ByteArrayOutputStream();
FrameCodec.write(out, image);
assertEquals(
MAX_IMAGE,
((Packet) FrameCodec.read(new ByteArrayInputStream(out.toByteArray()))).payload().length);
assertDoesNotThrow(
() ->
new Packet(
Kind.RESULT,
Operation.DISCOVER,
new Identity("s", "r", "c", "b", Map.of(), 0, 0),
new byte[MAX_METADATA]));
}
@Test
void handshakeAndImageRoundTripThroughFragmentedFrames() throws Exception {
var out = new ByteArrayOutputStream();
FrameCodec.write(out, hello());
var request = render(session());
FrameCodec.write(out, response(request));
var fragmented =
new FilterInputStream(new ByteArrayInputStream(out.toByteArray())) {
@Override
public int read(byte[] b, int off, int len) throws IOException {
return super.read(b, off, Math.min(1, len));
}
};
assertEquals(hello(), FrameCodec.read(fragmented));
var decoded = (Packet) FrameCodec.read(fragmented);
assertEquals(request.identity(), decoded.identity());
assertArrayEquals(new byte[16], decoded.payload());
assertThrows(EOFException.class, () -> FrameCodec.read(fragmented));
}
@Test
void lengthIsRejectedBeforeReadingOrAllocatingPayload() throws Exception {
for (int length : new int[] {-1, 0, 4, MAX_FRAME + 1, Integer.MAX_VALUE}) {
byte[] prefix = java.nio.ByteBuffer.allocate(4).putInt(length).array();
var in =
new ByteArrayInputStream(prefix) {
@Override
public synchronized byte[] readNBytes(int len) {
fail("Must reject length before payload allocation");
return null;
}
};
assertThrows(IOException.class, () -> FrameCodec.read(in));
}
var out = new ByteArrayOutputStream();
FrameCodec.write(out, hello());
byte[] bytes = out.toByteArray();
assertThrows(
EOFException.class,
() -> FrameCodec.read(new ByteArrayInputStream(Arrays.copyOf(bytes, bytes.length - 1))));
bytes[4] = 0;
assertThrows(IOException.class, () -> FrameCodec.read(new ByteArrayInputStream(bytes)));
}
@Test
void strictFieldValidationRejectsBadDimensionsEncodingAndTrailingBytes() throws Exception {
assertThrows(
IllegalArgumentException.class,
() -> new Identity("s", "r", "c", "b", Map.of("v", 1L), 8192, 8192));
var request = render(session());
assertThrows(
IllegalArgumentException.class,
() -> new Packet(Kind.RESULT, Operation.RENDER, request.identity(), new byte[15]));
assertThrows(
IllegalArgumentException.class,
() ->
new Packet(
Kind.REQUEST, Operation.RENDER, request.identity(), new byte[MAX_METADATA + 1]));
assertThrows(
IllegalArgumentException.class,
() ->
new Packet(
Kind.ERROR, Operation.RENDER, request.identity(), "unstructured error".getBytes()));
assertThrows(
IllegalArgumentException.class,
() ->
new Packet(
Kind.REQUEST, Operation.RENDER, request.identity(), new byte[] {(byte) 0xff}));
var out = new ByteArrayOutputStream();
FrameCodec.write(out, hello());
byte[] original = out.toByteArray(), extra = Arrays.copyOf(original, original.length + 1);
java.nio.ByteBuffer.wrap(extra).putInt(original.length - 3);
assertThrows(IOException.class, () -> FrameCodec.read(new ByteArrayInputStream(extra)));
}
@Test
void authenticationRequiresLoopbackTokenSessionAndExpectedWorkerAndCannotRetry()
throws Exception {
var unauthenticated = new ProtocolSession("session", 42, TOKEN, CAPS, Set.of());
assertThrows(IllegalStateException.class, () -> render(unauthenticated));
for (int test = 0; test < 4; test++) {
var s = new ProtocolSession("session", 42, TOKEN, CAPS, Set.of());
var h = hello();
var bad =
new Hello(
test == 1 ? "other" : h.session(),
test == 2 ? 43 : 42,
test == 0 ? "b".repeat(64) : TOKEN,
h.wire(),
h.metadata(),
h.preview(),
CAPS,
Set.of());
InetAddress address =
test == 3
? InetAddress.getByAddress(new byte[] {(byte) 192, 0, 2, 1})
: InetAddress.getLoopbackAddress();
assertThrows(IllegalArgumentException.class, () -> s.authenticate(address, bad));
assertThrows(
IllegalStateException.class,
() -> s.authenticate(InetAddress.getLoopbackAddress(), hello()));
}
assertFalse(hello().toString().contains(TOKEN));
assertNotEquals(ProtocolSession.newToken(), ProtocolSession.newToken());
}
@Test
void versionsAndCapabilitiesGateDiscoveryAndRenderingIndependently() throws Exception {
var h = hello();
var s = new ProtocolSession("session", 42, TOKEN, CAPS, Set.of());
assertEquals(
Set.of(Operation.DISCOVER),
s.authenticate(
InetAddress.getLoopbackAddress(),
new Hello(
h.session(),
42,
TOKEN,
h.wire(),
h.metadata(),
new Version(2, 0),
CAPS,
Set.of())));
assertThrows(IllegalStateException.class, () -> render(s));
var rendering = new ProtocolSession("session", 42, TOKEN, CAPS, Set.of());
assertEquals(
Set.of(Operation.RENDER),
rendering.authenticate(
InetAddress.getLoopbackAddress(),
new Hello(
h.session(), 42, TOKEN, h.wire(), new Version(2, 0), h.preview(), CAPS, Set.of())));
var major = new ProtocolSession("session", 42, TOKEN, CAPS, Set.of());
assertThrows(
IllegalArgumentException.class,
() ->
major.authenticate(
InetAddress.getLoopbackAddress(),
new Hello(
h.session(),
42,
TOKEN,
new Version(2, 0),
h.metadata(),
h.preview(),
CAPS,
Set.of())));
var required = new ProtocolSession("session", 42, TOKEN, CAPS, Set.of());
assertThrows(
IllegalArgumentException.class,
() ->
required.authenticate(
InetAddress.getLoopbackAddress(),
new Hello(
h.session(),
42,
TOKEN,
h.wire(),
h.metadata(),
h.preview(),
CAPS,
Set.of("unknown-required"))));
var optional = new ProtocolSession("session", 42, TOKEN, CAPS, Set.of());
var extras = new HashSet<>(CAPS);
extras.add("unknown-optional");
assertEquals(
2,
optional
.authenticate(
InetAddress.getLoopbackAddress(),
new Hello(
h.session(),
42,
TOKEN,
new Version(1, 99),
h.metadata(),
h.preview(),
extras,
Set.of()))
.size());
}
@Test
void requiredRenderingCannotNegotiateAnIncompatiblePreviewFamily() throws Exception {
var h = hello();
var s = new ProtocolSession("session", 42, TOKEN, CAPS, Set.of("render-rgba-v1"));
assertThrows(
IllegalArgumentException.class,
() ->
s.authenticate(
InetAddress.getLoopbackAddress(),
new Hello(
h.session(),
42,
TOKEN,
h.wire(),
h.metadata(),
new Version(2, 0),
CAPS,
Set.of())));
}
@Test
void resultsMustMatchEveryIdentityAndAreAcceptedOnlyOnce() throws Exception {
var s = session();
var request = render(s);
var id = request.identity();
for (int t = 0; t < 6; t++) {
var wrong =
new Identity(
t == 0 ? "wrong" : id.session(),
t == 1 ? "wrong" : id.request(),
t == 2 ? "wrong" : id.context(),
t == 3 ? "wrong" : id.snapshot(),
t == 4 ? Map.of("View.fxml", 4L) : id.revisions(),
t == 5 ? 1 : 2,
2);
assertFalse(
s.accept(
new Packet(Kind.RESULT, Operation.RENDER, wrong, new byte[t == 5 ? 8 : 16]), NOW));
}
assertTrue(s.accept(response(request), NOW));
assertFalse(s.accept(response(request), NOW));
}
@Test
void cancelTimeoutErrorAndDisconnectRetireRequests() throws Exception {
var s = session();
var cancelled = render(s);
assertEquals(Kind.CANCEL, s.cancel(cancelled.identity().request()).orElseThrow().kind());
assertFalse(s.accept(response(cancelled), NOW));
var timeout = render(s);
assertEquals(1, s.expire(NOW.plusSeconds(5)).size());
assertFalse(s.accept(response(timeout), NOW.plusSeconds(5)));
var failed = render(s);
assertTrue(
s.accept(
new Packet(
Kind.ERROR,
Operation.RENDER,
failed.identity(),
"LOAD_FAILED\nComponent construction failed".getBytes()),
NOW));
assertFalse(s.accept(response(failed), NOW));
s.close();
assertThrows(IllegalStateException.class, () -> render(s));
}
@Test
void pendingRequestsAndTimeoutsAreBounded() throws Exception {
var s = session();
assertThrows(
IllegalArgumentException.class,
() ->
s.request(
Operation.DISCOVER, "c", "b", Map.of(), 0, 0, new byte[0], NOW, Duration.ZERO));
for (int i = 0; i < MAX_PENDING; i++)
s.request(
Operation.DISCOVER, "c", "b", Map.of(), 0, 0, new byte[0], NOW, Duration.ofSeconds(1));
assertThrows(
IllegalStateException.class,
() ->
s.request(
Operation.DISCOVER,
"c",
"b",
Map.of(),
0,
0,
new byte[0],
NOW,
Duration.ofSeconds(1)));
assertEquals(MAX_PENDING, s.expire(NOW.plusSeconds(1)).size());
}
}
@@ -0,0 +1 @@
0000002d4e4658310100000000000173000000013100000001630000000162000000000000000000000000000000027b7d