diff --git a/CHANGELOG.md b/CHANGELOG.md index 8608a0f..7a2afba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ format changes. ### Changed +- **`Convert.protoToXml` writes a `bytes` field as a `uint8` array** (#179; draft `-01` § Fixed-Length Arrays). A `bytes` field with `(sbe.length) = N` was written as a `char` array, which `Convert.xmlToProto` reads back as a `string`, so a `bytes` field did not survive a round trip. It is now `primitiveType="uint8" length="N"`, declared once per length as `bytesN` beside the `strN` types. The wire bytes are the same. A `bytes` field of length 1 is written as `length="1"`, which FIX SBE reads as a single `uint8`, so it reads back as `uint32` with `(sbe.encoding) = "uint8"`: the same byte in the block, not `bytes`. This follows protowire-go#251. +- **`Convert.xmlToProto` refuses an array of a primitive wider than a byte** (#179). This narrows what imports. A type such as `primitiveType="int32" length="4"` used to import as one `int32`, which misplaced every later field in the block. No proto field holds such an array, so the import now throws `IllegalArgumentException` naming the type, the field and the message. - **The lite tier prints nothing for a plain field whose wire value is zero** (#180). `LiteWireReader` used to print `f_int32 = 0` for `2800`, where protowire-go and the full tier print nothing: a plain proto3 field has no set state, so a zero on the wire means the same as no field. A field in `PxfMeta.implicitPresenceFields()` whose last value is its zero value is now left out. A `-0.0`, an `optional` field and a map entry's key and value keep their zeros. A companion from an older plugin has an empty table and prints as before. - **Both tiers write PXF text to the same PB bytes as protowire-go** (#156, #157, #158; the spec's write gate, protowire#511). Each change below used to make a valid document write different bytes from Go, or accept one Go refuses: @@ -54,6 +56,7 @@ format changes. ### Fixed +- **`Convert.xmlToProto` keeps the length of a `uint8` or `int8` array** (#179). `` imported as one `uint32` with `(sbe.encoding) = "uint8"`, so a 16-byte field was read as 1 byte and every later field in the block was misplaced. It now imports as `bytes` with `(sbe.length) = 16`, the same 16 bytes on the wire. - **The SBE decoder rejects a string field that is not UTF-8** (#162; `docs/HARDENING.md` § UTF-8). Both tiers turned the bytes of an `(sbe.length)` `string` into a `String` with `new String(bytes, UTF_8)`, which puts U+FFFD in place of bad bytes and says nothing, so `41 42 FF FE` decoded as `"AB\uFFFD\uFFFD"`. `SbeWireCodec` and `View.stringValue` now decode strictly and throw `IllegalArgumentException` (`sbe: invalid UTF-8 in string field `). A `bytes` field takes any bytes, as before. `check-decode-android` decodes `adversarial.v1.SbeText`, so the lite tier runs the spec's `sbe/utf8-*` rows. - **An SBE emitter cuts an overlong string on a character boundary** (#166; draft -01 § SBE Annotations, protowire#495). Both tiers cut an `(sbe.length)` value at the byte limit, which can split a multi-byte character, and every strict decoder, this port's now included, then rejects the whole message. `ABCDEFGé` in a string field of length 8 was written `41 42 43 44 45 46 47 C3`. A string is now cut at the last whole character at or before the limit and NUL-padded: `41 42 43 44 45 46 47 00`. A `bytes` field is still cut at the limit. Only bytes that strict decoders refuse change. diff --git a/sbe/src/main/java/org/protowire/sbe/Convert.java b/sbe/src/main/java/org/protowire/sbe/Convert.java index bb4cc59..1e63e8d 100644 --- a/sbe/src/main/java/org/protowire/sbe/Convert.java +++ b/sbe/src/main/java/org/protowire/sbe/Convert.java @@ -71,7 +71,13 @@ static void writeProtoComposite(StringBuilder b, XmlSchema.XmlComposite c) { b.append("message ").append(c.name).append(" {\n"); int n = 1; for (XmlSchema.XmlType t : c.types) { - writeTypedField(b, " ", resolveTypeToProto(t), camelToSnake(t.name), n); + String[] mt; + try { + mt = resolveTypeToProto(t); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("sbe: composite \"" + c.name + "\": " + e.getMessage(), e); + } + writeTypedField(b, " ", mt, camelToSnake(t.name), n); n++; } for (XmlSchema.XmlRef r : c.refs) { @@ -86,8 +92,12 @@ static void writeProtoMessage(StringBuilder b, XmlSchema.XmlMessage m, Map 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 @@ + + + + + + + + + + + + + + + + + + +