Make configuration and compatibility contracts executable
This commit is contained in:
+45
@@ -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
|
||||
Reference in New Issue
Block a user