How do I build up binary data or write a network protocol message in EK9?

← Advanced Type System · Ref: Q1385

BytesBuilder is the mutable working buffer - the write side of the family, and the counterpart to BytesReader.

WHY IT EXISTS (it is NOT just 'a faster Bytes')
Bytes is a value, so every + and | copies the whole accumulator; building byte-by-byte is quadratic. That matters for bulk work, but it is not the reason this type exists. Three things are inexpressible on Bytes at ANY speed:

  1. ENDIAN-AWARE WRITES. BytesReader has readUInt32BE; without a builder there is no endian-aware write anywhere in the language, so you hand-assemble bytes and reintroduce the endianness bugs the reader eliminated.
  2. IN-PLACE MUTATION - setByte, xorInto, copyInto. A value type cannot offer these; every operation on Bytes yields a new array.
  3. WIPE. A Bytes CANNOT be securely erased, because + and | already scattered copies of it. A builder is one buffer you can actually zero.

CREATING

  builder <- BytesBuilder()               // empty, and SET - empty is not unset
  sized <- BytesBuilder(1024)             // reserves capacity; length is still 0
  seeded <- BytesBuilder(someBytes)       // initialise from an immutable value

Capacity is backing storage, not content. reserve(n) grows it and never shrinks. Growth is geometric, so appending is amortised rather than quadratic.

APPENDING

  builder.append(aByte) / append(someBytes) / append(anotherBuilder)
  builder += aByte                        // and | through a stream terminal

The | and += spellings carry over from Bytes deliberately, so the familiar accumulation idiom reads the same - it is simply amortised here.

TYPED WRITES MIRROR THE READER EXACTLY

  appendUInt16BE/LE, appendUInt32BE/LE, appendUInt64BE/LE
  appendInt16BE/LE,  appendInt32BE/LE,  appendInt64BE/LE

Twelve writes against BytesReader's twelve reads. There is no appendUInt32 with a default, for the same reason there is no readUInt32: a silent endianness assumption is a bug category, so the defaulted spelling does not exist.

RANGE IS ENFORCED, NOT TRUNCATED

appendUInt16BE(0x10000) makes the builder UNSET rather than writing 0x0000. And appendUInt64BE(-1) is REFUSED rather than written as two's complement - the mirror image of readUInt64BE refusing to hand back a negative for a large positive. Use appendInt64BE when you mean a signed value.

IN-PLACE MUTATION

  builder.setByte(index, aByte)
  builder.xorInto(offset, source)
  builder.copyInto(offset, source)

All three write WITHIN the current length and never extend - use append to grow. A write that would run past the end is refused OUTRIGHT rather than part-applied, because half an applied round key is worse than none. xorInto is one primitive with three uses: AES AddRoundKey, the Argon2 block XOR and the ChaCha20 keystream application.

SECURE HYGIENE

  secret.wipe()

Zeroes the backing store and leaves the builder usable. Best-effort by nature - the JVM may have moved the buffer during GC and the OS may have paged it - but it is the only place erasure is even possible.

CROSSING BACK

  frozen <- builder.toBytes()

The single visible boundary between 'doing byte work' and handing a value to the rest of the program. It always COPIES, so a snapshot cannot change underneath its holder.

AND $ SHOWS SHAPE, NOT CONTENT

$builder gives BytesBuilder(4/16) - the length and capacity, never the bytes. A buffer holding a key must not leak into a log line through a debug print. For the same reason there is no #? - a hash that changes as you append makes a mutable key look usable, and that is a bug.

See Q1384 for BytesReader, Q1383 for Bytes and Q1382 for Byte. Use 'ek9 -h BytesBuilder' for the full API.

Example

defines module qa.bytesbuilder.usage

  defines constant

    tlsVersionTls12 <- 0x0303
    tlsBodyLength <- 8

  defines program

    BytesBuilderUsage()
      stdout <- Stdout()

      //The TLS record Q1384 parses - endianness is in the method name.
      record <- BytesBuilder()
      record.append(Byte("16"))
      record.appendUInt16BE(tlsVersionTls12)
      record.appendUInt16BE(tlsBodyLength)
      record.append(Bytes("034142430358595a"))

      framed <- record.toBytes()
      stdout.println(`framed: ${framed}`)

      //It reads back through the reader.
      cursor <- framed.reader()
      stdout.println(`type: ${cursor.readByte()}`)
      stdout.println(`version: ${cursor.readUInt16BE()}`)
      stdout.println(`declared length: ${cursor.readUInt16BE()}`)

      //BE and LE genuinely differ - no defaulted spelling exists.
      bigEndian <- BytesBuilder()
      bigEndian.appendUInt32BE(0x01020304)
      littleEndian <- BytesBuilder()
      littleEndian.appendUInt32LE(0x01020304)
      stdout.println(`big endian: ${bigEndian.toBytes()}`)
      stdout.println(`little endian: ${littleEndian.toBytes()}`)

      //Too wide for the field is REJECTED, never truncated.
      tooWide <- BytesBuilder()
      tooWide.appendUInt16BE(0x10000)
      stdout.println(`too wide accepted: ${tooWide?}`)

      //Negative unsigned is refused; the signed spelling accepts it.
      negativeUnsigned <- BytesBuilder()
      negativeUnsigned.appendUInt64BE(-1)
      stdout.println(`negative unsigned accepted: ${negativeUnsigned?}`)
      negativeSigned <- BytesBuilder()
      negativeSigned.appendInt64BE(-1)
      stdout.println(`negative signed: ${negativeSigned.toBytes()}`)

      //xorInto is AddRoundKey / Argon2 block XOR / ChaCha20 keystream, self-inverse.
      block <- BytesBuilder(Bytes("ffffffff"))
      block.xorInto(0, Bytes("0f0f0f0f"))
      stdout.println(`after xor: ${block.toBytes()}`)
      block.xorInto(0, Bytes("0f0f0f0f"))
      stdout.println(`xor undone: ${block.toBytes()}`)

      //A write past the end is refused outright.
      overrun <- BytesBuilder(Bytes("ffffffff"))
      overrun.xorInto(2, Bytes("0f0f0f"))
      stdout.println(`overrun accepted: ${overrun?}`)

      //wipe zeroes the buffer - an immutable Bytes cannot be erased.
      secret <- BytesBuilder(Bytes("cafebabedeadbeef"))
      secret.wipe()
      stdout.println(`wiped length: ${length secret}`)
      stdout.println(`still usable: ${secret?}`)

      //toBytes always COPIES - a snapshot cannot move under its holder.
      snapshotting <- BytesBuilder(Bytes("deadbeef"))
      snapshot <- snapshotting.toBytes()
      snapshotting.append(Byte("FF"))
      stdout.println(`snapshot: ${snapshot}`)
      stdout.println(`builder now: ${snapshotting.toBytes()}`)

      //$ reports SHAPE, never CONTENT - a key must not reach a log line.
      stdout.println(`shape only: ${$snapshotting}`)
Other ways to ask this
  • Is there a StringBuilder equivalent for bytes in EK9?
  • How do I write a big-endian integer into bytes?
  • How do I XOR one buffer into another in EK9?
  • How do I erase a key or password buffer in EK9?

Coming from another language?

Java: ByteBuffer.putInt writes in whatever order the buffer's order() setting happens to be - decided elsewhere in the program - and silently truncates a long into an int field; a password in a String cannot be erased at all, which is why the JDK's own APIs take char[]. C: a memcpy of a struct writes host byte order and host padding, correct on the machine it was written on, and memset on a dead buffer may be optimised away entirely - which is why memset_s and explicit_bzero exist. Python: struct.pack raises on an out-of-range value, which is right, but the endianness prefix is easy to omit and native order is then used; bytes are immutable so their copies cannot be cleared. Go: binary.Write takes an explicit binary.BigEndian, but a too-wide value is handled by the conversion at the call site, so the truncation just moves. EK9 puts the endianness in the method name, makes an out-of-range write visibly absent instead of quietly plausible, and gives key material one buffer that can actually be zeroed.

Keywords: wipe, buffer, xor, mutable, bytesbuilder, crypto, append, builder, littleendian, binary, endian, bigendian