Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .github/workflows/maven-verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: Maven Verify

on:
pull_request:
branches: [main, develop]
branches: [main, dev]
push:
branches: [main, develop]
branches: [main, dev]

jobs:
verify:
Expand Down
153 changes: 84 additions & 69 deletions README.md

Large diffs are not rendered by default.

32 changes: 31 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

<groupId>com.flamingo</groupId>
<artifactId>llp-core</artifactId>
<version>3.0.1</version>
<version>3.1.0</version>
<packaging>jar</packaging>

<name>LLP Core</name>
Expand Down Expand Up @@ -88,6 +88,13 @@
<version>5.23.0</version>
<scope>test</scope>
</dependency>
<!-- JSON parsing for spec test vectors -->
<dependency>
<groupId>tools.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>3.1.3</version>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand Down Expand Up @@ -201,4 +208,27 @@
</repository>
</distributionManagement>

<profiles>
<profile>
<id>slow-tests</id>
<properties>
<llp.test.includeSlow>true</llp.test.includeSlow>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>${maven-surefire-plugin.version}</version>
<configuration>
<systemPropertyVariables>
<llp.test.includeSlow>${llp.test.includeSlow}</llp.test.includeSlow>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>

</project>
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,12 @@ public enum CoreParseErrorReason implements ParseErrorReason {
/**
* A plugin threw an unexpected exception.
*/
PLUGIN_EXCEPTION("Layer parser threw an exception");
PLUGIN_EXCEPTION("Layer parser threw an exception"),

/**
* Layer chain ended without a FinalNode (ID 0x00).
*/
MISSING_FINAL_NODE("Layer chain ended without a FinalNode");

private final String reason;

Expand Down
25 changes: 21 additions & 4 deletions src/main/java/com/flamingo/comm/llp/core/SimpleFrameParser.java
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
package com.flamingo.comm.llp.core;

import com.flamingo.comm.llp.spi.LLPLayerParser;
import com.flamingo.comm.llp.spi.LayerParseInput;
import com.flamingo.comm.llp.spi.LayerParseResult;
import com.flamingo.comm.llp.spi.ParseErrorReason;
import com.flamingo.comm.llp.spi.*;
import com.flamingo.comm.llp.util.LayerIds;

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.List;
import java.util.Optional;

final class SimpleFrameParser implements LLPFrameParser {
Expand All @@ -30,6 +28,7 @@ public LLPFrame parse(LLPRawFrame rawFrame) {
buffer.order(ByteOrder.BIG_ENDIAN);

NodeChain.Builder chainBuilder = new NodeChain.Builder();
boolean foundFinal = false;

loop:while (buffer.hasRemaining()) {

Expand All @@ -38,6 +37,7 @@ public LLPFrame parse(LLPRawFrame rawFrame) {
// Final layer (ID = 0)
if (LayerIds.isFinal(layerId)) {
chainBuilder.add(FinalNode.of(buffer.slice()));
foundFinal = true;
break loop;
}

Expand Down Expand Up @@ -153,6 +153,23 @@ public LLPFrame parse(LLPRawFrame rawFrame) {
}
}

if (!foundFinal && !chainBuilder.build().asList().isEmpty()) {
List<LLPNode> nodes = chainBuilder.build().asList();
LLPNode last = nodes.getLast();
if (last instanceof FailureNode) {
// Chain already terminated with a failure — no need to add MISSING_FINAL_NODE
} else if (last instanceof FinalNode) {
// Should not happen since foundFinal == false, but guard anyway
} else {
// Chain ended because buffer ran out, but not due to a parse error.
// The layer chain is missing a FinalNode.
chainBuilder.add(new FailureNode(
0,
CoreParseErrorReason.MISSING_FINAL_NODE
));
}
}

return new LLPFrame(
chainBuilder.build(),
rawFrame.crc(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ public enum TransportErrorCode {
PAYLOAD_LEN_INVALID((byte) 0x02, "Payload length exceeds maximum"),
TIMEOUT((byte) 0x03, "Frame timeout - incomplete frame"),
SYNC_ERROR((byte) 0x04, "Synchronization error"),
BUFFER_FULL((byte) 0x05, "Buffer overflow");
BUFFER_FULL((byte) 0x05, "Buffer overflow"),
LAYER_MALFORMED((byte) 0x06, "Layer chain malformed"),
TRANSFORM_NO_HANDLER((byte) 0x07, "No handler registered for transform layer");

private final byte code;
private final String description;
Expand Down
44 changes: 29 additions & 15 deletions src/main/java/com/flamingo/comm/llp/util/LayerIds.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,18 @@
* <li>Can be safely skipped if no parser is available.</li>
* </ul>
* </li>
* <li><b>Non-skippable Layers (ID 128–255)</b>:
* <li><b>Non-skippable / Transform Layers (ID 128–254)</b>:
* <ul>
* <li>Modify the payload (e.g., encryption, compression).</li>
* <li>Must be parsed to correctly interpret subsequent layers.</li>
* </ul>
* </li>
* <li><b>Reserved Layer (ID = 255)</b>:
* <ul>
* <li>Reserved for future use.</li>
* <li>Parsers should treat as unknown and skip if possible.</li>
* </ul>
* </li>
* </ul>
*
* <p>
Expand All @@ -45,10 +51,16 @@ public final class LayerIds {
static final int FINAL_LAYER_ID = 0;

/**
* Threshold from which layers are considered non-skippable.
* Threshold from which layers are considered non-skippable (transform).
*/
private static final int NON_SKIPPABLE_THRESHOLD = 128;

/**
* Reserved layer identifier. Per the LLP specification, parsers should
* treat this ID as unknown and skip if possible.
*/
static final int RESERVED_LAYER_ID = 255;

private LayerIds() {
// Utility class (no instances)
}
Expand Down Expand Up @@ -76,13 +88,15 @@ public static boolean isFinal(int id) {
* <p>
* Note: The final layer (ID = 0) is also considered non-modifying, but it is
* excluded from this method since it has special structural semantics.
* The reserved ID (255) is considered skippable per the LLP specification.
* </p>
*
* @param id layer identifier
* @return {@code true} if the layer is skippable (ID 1–127), otherwise {@code false}
* @return {@code true} if the layer is skippable (ID 1–127 or 255), otherwise {@code false}
*/
public static boolean isSkippable(int id) {
return id > FINAL_LAYER_ID && id < NON_SKIPPABLE_THRESHOLD;
return (id > FINAL_LAYER_ID && id < NON_SKIPPABLE_THRESHOLD)
|| id == RESERVED_LAYER_ID;
}

/**
Expand All @@ -91,31 +105,31 @@ public static boolean isSkippable(int id) {
* <p>
* Non-skippable layers modify the payload (e.g., encryption or compression),
* therefore they must be successfully parsed before continuing to inner layers.
* The reserved ID (255) is <em>not</em> considered non-skippable per the
* LLP specification, which states it should be skipped if possible.
* </p>
*
* @param id layer identifier
* @return {@code true} if the layer is non-skippable (ID 128), otherwise {@code false}
* @return {@code true} if the layer is non-skippable (ID 128–254), otherwise {@code false}
*/
public static boolean isNonSkippable(int id) {
return id >= NON_SKIPPABLE_THRESHOLD;
return id >= NON_SKIPPABLE_THRESHOLD && id != RESERVED_LAYER_ID;
}

/**
* Checks whether the given layer ID represents a layer that does not modify payload.
* Checks whether the given layer ID is reserved.
*
* <p>
* This includes:
* <ul>
* <li>Final layer (ID = 0)</li>
* <li>Skippable layers (ID 1–127)</li>
* </ul>
* The reserved ID (255) is set aside for future use per the LLP specification.
* Parsers should treat it as unknown and skip if possible — it is not
* considered a transform (non-skippable) layer.
* </p>
*
* @param id layer identifier
* @return {@code true} if the layer does not modify payload, otherwise {@code false}
* @return {@code true} if the layer ID is reserved (255), otherwise {@code false}
*/
public static boolean doesNotModifyPayload(int id) {
return id < NON_SKIPPABLE_THRESHOLD;
public static boolean isReserved(int id) {
return id == RESERVED_LAYER_ID;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ void shouldParseFrameWithOnlyFinalLayer() {
assertEquals(1234, frame.crc());
assertEquals(1000L, frame.timestamp());

// Node chain should have exactly 1 node (FinalNode)
// Node chain should have exactly 1 node (FinalNode)00
assertEquals(1, frame.chain().size());
assertInstanceOf(FinalNode.class, frame.chain().asList().getFirst());
}
Expand Down Expand Up @@ -289,8 +289,11 @@ void shouldStopParsingWhenPluginReturnsEmptyPayload() {

LLPFrame frame = parser.parse(rawFrame);

assertEquals(1, frame.chain().size());
assertEquals(2, frame.chain().size());
assertEquals(mockNode, frame.chain().asList().getFirst());
assertInstanceOf(FailureNode.class, frame.chain().asList().get(1));
FailureNode fn = (FailureNode) frame.chain().asList().get(1);
assertEquals(CoreParseErrorReason.MISSING_FINAL_NODE, fn.getErrorReason());
}

@Test
Expand Down
130 changes: 130 additions & 0 deletions src/test/java/com/flamingo/comm/llp/spec/LayerTraversalVectorTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package com.flamingo.comm.llp.spec;

import com.flamingo.comm.llp.core.FailureNode;
import com.flamingo.comm.llp.core.FinalNode;
import com.flamingo.comm.llp.core.LLP;
import com.flamingo.comm.llp.core.LLPFrame;
import com.flamingo.comm.llp.core.LLPFrameParser;
import com.flamingo.comm.llp.core.LLPRawFrame;
import com.flamingo.comm.llp.core.LLPTransportDeframer;
import com.flamingo.comm.llp.core.TransportErrorCode;
import tools.jackson.databind.JsonNode;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.*;

class LayerTraversalVectorTest {

private static final HexFormat HEX = HexFormat.of();

static List<TestVector> loadVectors() throws IOException {
return VectorLoader.loadByNames("llp-vectors", "traversal.json");
}

@ParameterizedTest
@MethodSource("loadVectors")
void testTraversal(TestVector vector) throws Exception {
String frameHex = vector.input().get("frame_hex").asString();
byte[] frame = HEX.parseHex(frameHex);

JsonNode expected = vector.expected();
String outcome = expected.get("outcome").asString();
String expectedFinalHex = expected.has("final_payload_hex")
? expected.get("final_payload_hex").asString()
: "";

LLPTransportDeframer deframer = new LLPTransportDeframer();
LLPFrameParser frameParser = LLP.frameParser()
.parserProvider(id -> Optional.empty())
.build();

List<LLPRawFrame> rawFrames = new ArrayList<>();
List<TransportErrorCode> transportErrors = new ArrayList<>();
deframer.addListener(new LLPTransportDeframer.LLPFrameListener() {
@Override
public void onFrameReceived(LLPRawFrame rawFrame) {
rawFrames.add(rawFrame);
}

@Override
public void onFrameError(TransportErrorCode code) {
transportErrors.add(code);
}
});

deframer.processBytes(frame);

if ("FRAME".equals(outcome)) {
assertFalse(rawFrames.isEmpty(), "[" + vector.name() + "] expected at least 1 raw frame");
assertTrue(transportErrors.isEmpty(),
"[" + vector.name() + "] expected no transport errors");

LLPFrame parsed = frameParser.parse(rawFrames.getFirst());
byte[] finalPayload = extractFinalPayload(parsed);
byte[] expectedFinal = expectedFinalHex.isEmpty()
? new byte[0]
: HEX.parseHex(expectedFinalHex);
assertArrayEquals(expectedFinal, finalPayload,
"[" + vector.name() + "] final payload mismatch");
} else if ("ERROR".equals(outcome)) {
String expectedErrorCode = expected.get("error_code").asString();

if ("TRANSFORM_NO_HANDLER".equals(expectedErrorCode)) {
assertFalse(rawFrames.isEmpty(), "[" + vector.name() + "] expected at least 1 raw frame");
assertTrue(transportErrors.isEmpty(),
"[" + vector.name() + "] expected no transport errors");

LLPFrame parsed = frameParser.parse(rawFrames.getFirst());
boolean hasFailureNode = parsed.chain().asList().stream()
.anyMatch(node -> node instanceof FailureNode);
assertTrue(hasFailureNode,
"[" + vector.name() + "] expected FailureNode for TRANSFORM_NO_HANDLER");
} else {
assertFalse(transportErrors.isEmpty(), "[" + vector.name() + "] expected at least 1 transport error");
TransportErrorCode expectedError = mapErrorCode(expectedErrorCode);
assertEquals(expectedError, transportErrors.getFirst(),
"[" + vector.name() + "] error code mismatch");
}
}
}

private static byte[] extractFinalPayload(LLPFrame frame) {
List<?> nodes = frame.chain().asList();
FinalNode fn = null;
for (Object node : nodes) {
if (node instanceof FinalNode) {
fn = (FinalNode) node;
break;
}
}
if (fn == null) {
return new byte[0];
}
ByteBuffer buf = fn.getPayload();
byte[] result = new byte[buf.remaining()];
buf.get(result);
return result;
}

private static TransportErrorCode mapErrorCode(String specCode) {
return switch (specCode) {
case "CHECKSUM" -> TransportErrorCode.CHECKSUM_INVALID;
case "TIMEOUT" -> TransportErrorCode.TIMEOUT;
case "SYNC_ERROR" -> TransportErrorCode.SYNC_ERROR;
case "PAYLOAD_LEN_INVALID" -> TransportErrorCode.PAYLOAD_LEN_INVALID;
case "BUFFER_FULL" -> TransportErrorCode.BUFFER_FULL;
case "LAYER_MALFORMED" -> TransportErrorCode.LAYER_MALFORMED;
case "TRANSFORM_NO_HANDLER" -> TransportErrorCode.TRANSFORM_NO_HANDLER;
default -> throw new IllegalArgumentException(
"Unknown spec error code: " + specCode);
};
}
}
Loading
Loading