enumMap) {
b.append("message ").append(m.name).append(" {\n");
b.append(" option (sbe.template_id) = ").append(m.id).append(";\n");
- for (XmlSchema.XmlField f : m.fields) writeProtoField(b, f, typeMap, compMap, enumMap, " ");
- for (XmlSchema.XmlGroup g : m.groups) writeProtoGroup(b, g, typeMap, compMap, enumMap, " ");
+ try {
+ for (XmlSchema.XmlField f : m.fields) writeProtoField(b, f, typeMap, compMap, enumMap, " ");
+ for (XmlSchema.XmlGroup g : m.groups) writeProtoGroup(b, g, typeMap, compMap, enumMap, " ");
+ } catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("sbe: message \"" + m.name + "\": " + e.getMessage(), e);
+ }
b.append("}\n\n");
}
@@ -103,7 +113,13 @@ static void writeProtoField(StringBuilder b, XmlSchema.XmlField f,
}
XmlSchema.XmlType t = typeMap.get(f.type);
if (t != null) {
- writeTypedField(b, indent, resolveTypeToProto(t), name, f.id);
+ String[] mt;
+ try {
+ mt = resolveTypeToProto(t);
+ } catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("field \"" + f.name + "\": " + e.getMessage(), e);
+ }
+ writeTypedField(b, indent, mt, name, f.id);
return;
}
b.append(indent).append(f.type).append(' ').append(name).append(" = ").append(f.id).append(";\n");
@@ -116,7 +132,11 @@ static void writeProtoGroup(StringBuilder b, XmlSchema.XmlGroup g,
String indent) {
String msgName = singularPascal(g.name);
b.append(indent).append("message ").append(msgName).append(" {\n");
- for (XmlSchema.XmlField f : g.fields) writeProtoField(b, f, typeMap, compMap, enumMap, indent + " ");
+ try {
+ for (XmlSchema.XmlField f : g.fields) writeProtoField(b, f, typeMap, compMap, enumMap, indent + " ");
+ } catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("group \"" + g.name + "\": " + e.getMessage(), e);
+ }
b.append(indent).append("}\n");
b.append(indent).append("repeated ").append(msgName).append(' ').append(camelToSnake(g.name))
.append(" = ").append(g.id).append(";\n");
@@ -138,13 +158,32 @@ private static void writeTypedField(StringBuilder b, String indent, String[] mt,
* UTF-8, so a Latin-1 byte would fail the whole message (draft -01
* § Character Encoding, protowire#494, #173). As protowire-go's
* {@code xmltoproto.go} words it.
+ *
+ * A {@code uint8} or {@code int8} array (length above 1) becomes
+ * {@code bytes} of the same {@code (sbe.length)}: the same N bytes in the
+ * block. An array of a wider primitive is refused, naming the type, since
+ * no proto field holds it and importing one element would misplace every
+ * later field in the block (draft -01 § Fixed-Length Arrays, #179).
+ *
+ * @throws IllegalArgumentException for an array of a primitive wider than a byte
*/
static String[] resolveTypeToProto(XmlSchema.XmlType t) {
- String[] mt = resolveTypeToProto(t.primitiveType, t.length);
- if ("char".equals(t.primitiveType) && !isUtf8Compatible(t.characterEncoding)) {
- return new String[]{"bytes", mt[1], "SBE characterEncoding \"" + t.characterEncoding
- + "\" is not UTF-8 compatible, so this field is bytes."};
+ if ("char".equals(t.primitiveType)) {
+ String[] mt = resolveTypeToProto(t.primitiveType, t.length);
+ if (!isUtf8Compatible(t.characterEncoding)) {
+ return new String[]{"bytes", mt[1], "SBE characterEncoding \"" + t.characterEncoding
+ + "\" is not UTF-8 compatible, so this field is bytes."};
+ }
+ return new String[]{mt[0], mt[1], ""};
}
+ if (t.length > 1) {
+ if ("uint8".equals(t.primitiveType) || "int8".equals(t.primitiveType)) {
+ return new String[]{"bytes", "(sbe.length) = " + t.length, ""};
+ }
+ throw new IllegalArgumentException("type \"" + t.name + "\" is an array of " + t.length + " "
+ + t.primitiveType + ", which no proto field represents");
+ }
+ String[] mt = resolveTypeToProto(t.primitiveType, t.length);
return new String[]{mt[0], mt[1], ""};
}
@@ -196,6 +235,7 @@ public static String protoToXml(FileDescriptor... files) {
Integer version = SbeAnnotations.fileUint32(fd, SbeAnnotations.EXT_VERSION);
TreeSet strLengths = new TreeSet<>();
+ TreeSet bytesLengths = new TreeSet<>();
List composites = new ArrayList<>();
Set compSeen = new HashSet<>();
List enums = new ArrayList<>();
@@ -205,7 +245,7 @@ public static String protoToXml(FileDescriptor... files) {
for (Descriptor md : fd.getMessageTypes()) {
Integer tid = SbeAnnotations.messageUint32(md, SbeAnnotations.EXT_TEMPLATE_ID);
- if (tid != null) collectXmlTypes(md, strLengths, composites, compSeen, enums, enumSeen);
+ if (tid != null) collectXmlTypes(md, strLengths, bytesLengths, composites, compSeen, enums, enumSeen);
else if (compSeen.add(md.getFullName())) composites.add(md);
}
@@ -227,7 +267,8 @@ public static String protoToXml(FileDescriptor... files) {
b.append(" \n");
b.append(" \n");
b.append(" \n");
- for (int l : strLengths) b.append(" \n");
+ for (int l : strLengths) b.append(" \n");
+ for (int l : bytesLengths) b.append(" \n");
for (EnumDescriptor ed : enums) writeXmlEnum(b, ed);
for (Descriptor md : composites) writeXmlComposite(b, md);
b.append(" \n");
@@ -239,27 +280,32 @@ public static String protoToXml(FileDescriptor... files) {
return b.toString();
}
- static void collectXmlTypes(Descriptor md, TreeSet strLens, List composites,
+ /** The {@code } entry a string or bytes field of this fixed length refers to. */
+ static String strTypeName(int length) { return "str" + length; }
+ static String bytesTypeName(int length) { return "bytes" + length; }
+
+ static void collectXmlTypes(Descriptor md, TreeSet strLens, TreeSet bytesLens, List composites,
Set compSeen, List enums, Set enumSeen) {
for (EnumDescriptor ed : md.getEnumTypes()) {
if (enumSeen.add(ed.getFullName())) enums.add(ed);
}
for (FieldDescriptor f : md.getFields()) {
- if (f.getType() == FieldDescriptor.Type.STRING || f.getType() == FieldDescriptor.Type.BYTES) {
- Integer len = SbeAnnotations.fieldUint32(f, SbeAnnotations.EXT_LENGTH);
- if (len != null) strLens.add(len);
+ Integer len = SbeAnnotations.fieldUint32(f, SbeAnnotations.EXT_LENGTH);
+ if (len != null) {
+ if (f.getType() == FieldDescriptor.Type.STRING) strLens.add(len);
+ else if (f.getType() == FieldDescriptor.Type.BYTES) bytesLens.add(len);
}
if (f.getJavaType() == FieldDescriptor.JavaType.ENUM) {
EnumDescriptor ed = f.getEnumType();
if (enumSeen.add(ed.getFullName())) enums.add(ed);
}
if (f.getJavaType() == FieldDescriptor.JavaType.MESSAGE) {
- if (f.isRepeated()) collectXmlTypes(f.getMessageType(), strLens, composites, compSeen, enums, enumSeen);
+ if (f.isRepeated()) collectXmlTypes(f.getMessageType(), strLens, bytesLens, composites, compSeen, enums, enumSeen);
else {
Descriptor sub = f.getMessageType();
if (compSeen.add(sub.getFullName())) {
composites.add(sub);
- collectXmlTypes(sub, strLens, composites, compSeen, enums, enumSeen);
+ collectXmlTypes(sub, strLens, bytesLens, composites, compSeen, enums, enumSeen);
}
}
}
@@ -314,8 +360,7 @@ static void writeXmlField(StringBuilder b, FieldDescriptor fd, String indent) {
return;
}
SbeTypeInfo info = protoFieldToSbe(fd);
- if (info.length > 0) b.append(indent).append("\n");
- else b.append(indent).append("\n");
+ b.append(indent).append("\n");
}
static void writeXmlGroup(StringBuilder b, FieldDescriptor fd, String indent) {
@@ -327,14 +372,23 @@ static void writeXmlGroup(StringBuilder b, FieldDescriptor fd, String indent) {
b.append(indent).append("\n");
}
+ /**
+ * An SBE type for a proto field. {@code xmlType} is what a {@code }
+ * names: the primitive, or for a fixed-length array the {@code }
+ * entry declared for its length.
+ */
record SbeTypeInfo(String primitiveType, String xmlType, int length) {}
static SbeTypeInfo protoFieldToSbe(FieldDescriptor fd) {
String enc = SbeAnnotations.fieldString(fd, SbeAnnotations.EXT_ENCODING);
if (enc != null) return new SbeTypeInfo(enc, enc, 0);
+ // A bytes field is a uint8 array, not a char array: a char array
+ // imports back as a string (#179). Both are the same N bytes on the wire.
if (fd.getType() == FieldDescriptor.Type.STRING || fd.getType() == FieldDescriptor.Type.BYTES) {
Integer len = SbeAnnotations.fieldUint32(fd, SbeAnnotations.EXT_LENGTH);
- return new SbeTypeInfo("char", "char", len == null ? 0 : len);
+ if (len == null || len == 0) return new SbeTypeInfo("char", "char", 0);
+ if (fd.getType() == FieldDescriptor.Type.BYTES) return new SbeTypeInfo("uint8", bytesTypeName(len), len);
+ return new SbeTypeInfo("char", strTypeName(len), len);
}
return switch (fd.getType()) {
case BOOL -> new SbeTypeInfo("uint8", "uint8", 0);
diff --git a/sbe/src/test/java/org/protowire/sbe/FixedArrayTest.java b/sbe/src/test/java/org/protowire/sbe/FixedArrayTest.java
new file mode 100644
index 0000000..1355d5c
--- /dev/null
+++ b/sbe/src/test/java/org/protowire/sbe/FixedArrayTest.java
@@ -0,0 +1,171 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 TrendVidia, LLC.
+package org.protowire.sbe;
+
+import arr.v1.Arr.Key;
+import arr.v1.Arr.Record;
+import com.google.protobuf.ByteString;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Fixed-length arrays that are not text survive XML import and export (draft
+ * -01 § Fixed-Length Arrays, #179). A {@code uint8} or {@code int8} array
+ * imports as {@code bytes} of the same length, an array of a wider primitive is
+ * refused, and a {@code bytes} field exports as a {@code uint8} array. The
+ * schema and cases are protowire-go's {@code TestXMLToProtoOctetArray},
+ * {@code TestXMLToProtoRefusesWideArray}, {@code TestXMLRoundTripFixedArrays}
+ * and {@code TestXMLRoundTripBytesLengthOne}.
+ */
+final class FixedArrayTest {
+
+ private static String resource(String path) throws IOException {
+ try (InputStream in = FixedArrayTest.class.getResourceAsStream(path)) {
+ return new String(in.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ }
+
+ private static String schema() throws IOException {
+ return resource("/arrays/arr.xml");
+ }
+
+ private static String importXml(String xml) {
+ return Convert.xmlToProto(xml.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static void assertContains(String text, String line) {
+ assertTrue(text.contains(line), line + "\n--- in ---\n" + text);
+ }
+
+ @Test
+ void anOctetArrayImportsAsBytes() throws IOException {
+ String proto = importXml(schema());
+ for (String line : new String[] {
+ "bytes uuid = 2 [(sbe.length) = 16];",
+ "bytes signed = 3 [(sbe.length) = 3];",
+ "bytes raw = 1 [(sbe.length) = 6];",
+ "uint32 tail = 5;",
+ }) {
+ assertContains(proto, line);
+ }
+ }
+
+ /** The compiled copy is this output, so the build has compiled what the importer writes. */
+ @Test
+ void theCompiledProtoIsTheImportersOutput() throws IOException {
+ String compiled = resource("/arrays/arr.proto");
+ assertEquals(importXml(schema()), compiled.substring(compiled.indexOf("syntax = ")));
+ }
+
+ /** The block keeps its layout: before #179 the uuid took one byte and the tail moved. */
+ @Test
+ void theBlockKeepsItsLayout() {
+ Codec codec = Codec.of(Record.getDescriptor().getFile());
+ byte[] uuid = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, (byte) 0xFF};
+ byte[] data = codec.marshal(Record.newBuilder()
+ .setUuid(ByteString.copyFrom(uuid))
+ .setTail(0xA1B2C3D4)
+ .build());
+ // Header(8) + name(4) + uuid(16) + signed(3) + key(6+4) + tail(4).
+ assertEquals(8 + 4 + 16 + 3 + 10 + 4, data.length);
+ assertArrayEquals(uuid, Arrays.copyOfRange(data, 12, 28));
+ assertArrayEquals(new byte[] {(byte) 0xD4, (byte) 0xC3, (byte) 0xB2, (byte) 0xA1},
+ Arrays.copyOfRange(data, data.length - 4, data.length));
+
+ Record.Builder back = Record.newBuilder();
+ codec.unmarshal(data, back);
+ assertArrayEquals(uuid, back.getUuid().toByteArray());
+ assertEquals(0xA1B2C3D4, back.getTail());
+ }
+
+ /** No proto field holds an array of a wider primitive, so the import refuses it and names it. */
+ @ParameterizedTest
+ @CsvSource(delimiter = '|', value = {
+ "|",
+ "|",
+ })
+ void aWideArrayIsRefused(String types, String field) {
+ String xml = "\n"
+ + "\n"
+ + " " + types + "\n"
+ + " " + field + "\n"
+ + "";
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> importXml(xml));
+ assertContains(e.getMessage(), "which no proto field represents");
+ }
+
+ @Test
+ void aRefusalNamesTheTypeFieldAndMessage() throws IOException {
+ String xml = schema().replace("primitiveType=\"uint8\" length=\"16\"", "primitiveType=\"uint64\" length=\"2\"");
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> importXml(xml));
+ assertEquals("sbe: message \"Record\": field \"uuid\": type \"Uuid\" is an array of 2 uint64,"
+ + " which no proto field represents", e.getMessage());
+ }
+
+ /**
+ * XML → proto → XML → proto gives the same proto, apart from the comment
+ * naming the Latin-1 encoding, which the export does not carry. The same
+ * fields, types and annotations make the same descriptors, so the same
+ * values encode to the same bytes under both. Before #179 the export wrote
+ * a {@code bytes} field as a {@code char} array, which imports back as a
+ * {@code string}.
+ */
+ @Test
+ void xmlToProtoToXmlToProtoKeepsEveryField() throws IOException {
+ String proto1 = importXml(schema());
+ String xml2 = Convert.protoToXml(Record.getDescriptor().getFile());
+ assertContains(xml2, "");
+ assertContains(xml2, "");
+ assertContains(xml2, "");
+
+ String proto2 = importXml(xml2);
+ assertEquals(withoutComments(proto1), withoutComments(proto2));
+ assertTrue(proto1.contains("// SBE characterEncoding \"ISO-8859-1\""), proto1);
+ }
+
+ /** The compiled schema writes the bytes of the same values as the generated one reads. */
+ @Test
+ void theRoundTrippedSchemaCarriesEveryByte() {
+ Codec codec = Codec.of(Record.getDescriptor().getFile());
+ Record in = Record.newBuilder()
+ .setName(ByteString.copyFrom(new byte[] {'c', 'a', 'f', (byte) 0xE9}))
+ .setUuid(ByteString.copyFromUtf8("0123456789abcdef"))
+ .setSigned(ByteString.copyFrom(new byte[] {(byte) 0x80, 0x7F, 0x01}))
+ .setKey(Key.newBuilder().setRaw(ByteString.copyFromUtf8("abcdef")).setSeq(7))
+ .setTail(9)
+ .build();
+ Record.Builder back = Record.newBuilder();
+ codec.unmarshal(codec.marshal(in), back);
+ assertEquals(in, back.build());
+ }
+
+ private static String withoutComments(String proto) {
+ return proto.lines().filter(l -> !l.strip().startsWith("//")).collect(Collectors.joining("\n"));
+ }
+
+ /**
+ * A {@code bytes} field of length 1 exports as a {@code uint8} of length 1,
+ * which FIX SBE reads as a single {@code uint8}, so it imports back as
+ * {@code uint32} with {@code (sbe.encoding) = "uint8"}: the same byte in the
+ * block, not {@code bytes}. Pinned so a change to that reading is a
+ * decision, not an accident.
+ */
+ @Test
+ void aBytesFieldOfLengthOneComesBackAsOneByte() {
+ String xml = Convert.protoToXml(one.v1.One.M.getDescriptor().getFile());
+ assertContains(xml, "");
+ assertContains(importXml(xml), "uint32 b = 1 [(sbe.encoding) = \"uint8\"];");
+ }
+}
diff --git a/sbe/src/test/proto/arrays/arr.proto b/sbe/src/test/proto/arrays/arr.proto
new file mode 100644
index 0000000..d3924e2
--- /dev/null
+++ b/sbe/src/test/proto/arrays/arr.proto
@@ -0,0 +1,30 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 TrendVidia, LLC.
+// Convert.xmlToProto's output for src/test/resources/arrays/arr.xml, byte for
+// byte after this header (FixedArrayTest checks it), compiled here so the
+// build proves it compiles (#179).
+
+syntax = "proto3";
+
+package arr.v1;
+
+import "sbe/annotations.proto";
+
+option (sbe.schema_id) = 3;
+option (sbe.version) = 0;
+
+message Key {
+ bytes raw = 1 [(sbe.length) = 6];
+ uint32 seq = 2;
+}
+
+message Record {
+ option (sbe.template_id) = 1;
+ // SBE characterEncoding "ISO-8859-1" is not UTF-8 compatible, so this field is bytes.
+ bytes name = 1 [(sbe.length) = 4];
+ bytes uuid = 2 [(sbe.length) = 16];
+ bytes signed = 3 [(sbe.length) = 3];
+ Key key = 4;
+ uint32 tail = 5;
+}
+
diff --git a/sbe/src/test/proto/arrays/one.proto b/sbe/src/test/proto/arrays/one.proto
new file mode 100644
index 0000000..87202d9
--- /dev/null
+++ b/sbe/src/test/proto/arrays/one.proto
@@ -0,0 +1,16 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 TrendVidia, LLC.
+// A bytes field of length 1, for FixedArrayTest (#179).
+
+syntax = "proto3";
+
+package one.v1;
+
+import "sbe/annotations.proto";
+
+option (sbe.schema_id) = 1;
+
+message M {
+ option (sbe.template_id) = 1;
+ bytes b = 1 [(sbe.length) = 1];
+}
diff --git a/sbe/src/test/resources/arrays/arr.xml b/sbe/src/test/resources/arrays/arr.xml
new file mode 100644
index 0000000..f85c6e9
--- /dev/null
+++ b/sbe/src/test/resources/arrays/arr.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+