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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user