How do I parse binary data or a network protocol in EK9?

← Advanced Type System · Ref: Q1384

BytesReader is a forward-only cursor over a Bytes, obtained with aBytes.reader(). It is what you use for binary protocol and format parsing.

THE CENTRAL IDEA - MALFORMED INPUT IS A VALUE, NOT AN EXCEPTION
Every read is tri-state. A read that would run past the end returns UNSET and LEAVES THE CURSOR WHERE IT WAS. So parsing is a sequence of ordinary reads followed by a set-check, with no try/catch and no bounds test before every field. Because a failed read does not move the cursor, a smaller read can still succeed afterwards:

  cursor <- Bytes("010203").reader()
  complete <- cursor.readUInt32BE()?   // false - only three bytes
  start <- cursor.position()           // still 0
  smaller <- cursor.readUInt16BE()     // 0x0102 - works

CURSOR STATE

  .position()   bytes consumed so far
  .remaining()  bytes left
  .atEnd()      nothing left
  .peek()       next byte WITHOUT consuming it

ENDIANNESS IS ALWAYS EXPLICIT

There is no readUInt32() with a default. There is readUInt32BE and readUInt32LE, and nothing else. A silent endianness default is a known bug category - it is correct on the machine it was written on and wrong in production - so the defaulted spelling does not exist. The same applies at every width: readUInt16BE/LE, readUInt64BE/LE, and the signed readInt16BE/LE, readInt32BE/LE, readInt64BE/LE.

Signed reads sign-extend; unsigned ones do not:

  Bytes("fffe").reader().readInt16BE()   // -2
  Bytes("fffe").reader().readUInt16BE()  // 65534

WHY readUInt64BE CAN BE UNSET
EK9's Integer is 64-bit SIGNED, so an unsigned 64-bit value with the top bit set has no correct representation - returning it would hand back a NEGATIVE number for a large positive one. It is UNSET instead, and deliberately NOT consumed, so you can reinterpret the same bytes:

  wide <- Bytes("ffffffffffffffff").reader()
  fits <- wide.readUInt64BE()?   // false - will not fit
  signed <- wide.readInt64BE()   // -1 - same bytes, your interpretation

NAVIGATION

  .skip(n)    advances; answers false and does NOT move if it would run past the end - it never clamps
  .readBytes(n)  reads n bytes as a Bytes

SLICE - WHAT MAKES NESTED FORMATS SAFE

  body <- cursor.slice(declaredLength)

This is a bounded sub-reader over the next n bytes which CONSUMES them from the parent. The sub-reader cannot read past its own boundary however malformed its content is, and the parent carries on after it. That is exactly what a length-prefixed structure needs - a TLS extension inside a record, a DER SEQUENCE inside a SEQUENCE.

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

Example

defines module qa.bytesreader.usage

  defines program

    BytesReaderUsage()
      stdout <- Stdout()

      //A TLS-record shape: type, version, body length, then a length-prefixed body.
      record <- Bytes("1603030008034142430358595a")
      cursor <- record.reader()

      stdout.println(`type: ${cursor.readByte()}`)
      stdout.println(`version: ${cursor.readUInt16BE()}`)

      bodyLength <- cursor.readUInt16BE()
      stdout.println(`body length: ${bodyLength}`)

      //A bounded sub-reader - it cannot read past its own boundary however malformed the body is.
      body <- cursor.slice(bodyLength)
      fieldLength <- body.readByte().toInteger()
      stdout.println(`field: ${body.readBytes(fieldLength)}`)

      //slice consumed the body from the parent, so the parent is done.
      stdout.println(`parent at end: ${cursor.atEnd()}`)

      //A short read is UNSET and leaves the cursor alone, so a smaller read still works.
      short <- Bytes("010203").reader()
      stdout.println(`underflow set: ${short.readUInt32BE()?}`)
      stdout.println(`cursor unmoved: ${short.position()}`)
      stdout.println(`smaller read: ${short.readUInt16BE()}`)

      //An unsigned 64-bit value with the top bit set cannot fit a SIGNED Integer, so it is
      //unset rather than negative - and is not consumed, so the bytes can be reinterpreted.
      wide <- Bytes("ffffffffffffffff").reader()
      stdout.println(`uint64 fits: ${wide.readUInt64BE()?}`)
      stdout.println(`same bytes signed: ${wide.readInt64BE()}`)
Other ways to ask this
  • Is there a ByteBuffer equivalent in EK9?
  • How do I read a big-endian integer from bytes?
  • How do I safely parse a length-prefixed binary format?
  • Why is my readUInt64BE unset?

Coming from another language?

Java: ByteBuffer throws BufferUnderflowException on a short read, so untrusted input needs a try/catch or a hasRemaining() check per field; endianness is a mutable order() setting carried on the buffer, often set far from the read. C: reading past the end is undefined behaviour - nothing to catch, and the single most productive source of remote vulnerabilities in binary parsers. Python: struct.unpack raises while slicing silently returns short - two failure modes in one parse. Go: binary.Read returns an error per field, easy to assign and ignore. EK9: a short read is UNSET and does not move the cursor, so the ordinary and malformed paths are the same code; endianness is in the method name and cannot be defaulted; slice gives a hard boundary for nested structures.

Keywords: buffer, tls, cursor, protocol, parse, underflow, bytesreader, littleendian, slice, binary, endian, bigendian