Skip to content

refactor: use bit operations for the DNS over TCP length prefix - #3466

Open
pjfanning wants to merge 1 commit into
apache:mainfrom
pjfanning:dns-length-bit-twiddling
Open

refactor: use bit operations for the DNS over TCP length prefix#3466
pjfanning wants to merge 1 commit into
apache:mainfrom
pjfanning:dns-length-bit-twiddling

Conversation

@pjfanning

Copy link
Copy Markdown
Member

Motivation

RFC 1035 section 4.2.2 prefixes each DNS over TCP message with its length as two big endian bytes. Both directions in TcpDnsClient expressed this with arithmetic rather than bit operations:

def encodeLength(length: Int): ByteString =
  ByteString((length / 256).toByte, length.toByte)

def decodeLength(data: ByteString): Int =
  ((data(0).toInt + 256) % 256) * 256 + ((data(1) + 256) % 256)

The + 256) % 256 pairs are working around Byte being signed, which & 0xFF states directly, and the division and multiplication are a shift by 8.

Modification

def encodeLength(length: Int): ByteString =
  ByteString(((length >> 8) & 0xFF).toByte, (length & 0xFF).toByte)

def decodeLength(data: ByteString): Int =
  ((data(0) & 0xFF) << 8) | (data(1) & 0xFF)

Plus a short comment naming the RFC section, since the two byte big endian framing is not obvious from the code alone.

This is behaviour preserving, not a fix

I want to be clear that this fixes nothing — the original was correct. I verified equivalence exhaustively rather than by inspection:

  • decodeLength: identical over all 65,536 byte pairs (every (Byte, Byte) combination), producing the full [0, 65535] range in both forms.
  • encodeLength: identical over the entire valid length range 0 to 65535.

The two forms diverge in exactly one place: encodeLength(-1) gives (0, -1) under the old form and (-1, -1) under the new one. That cannot occur — the only caller is encodeLength(bytes.length) at TcpDnsClient.scala:62, where bytes is a ByteString, so the argument is never negative. I checked the call site rather than assuming.

So the case for merging is readability, and a small amount of work removed from the receive path (two divisions and a multiplication become a shift and two masks). If you would rather not take churn in working DNS framing code for that, this is a reasonable one to decline.

Tests

Added a "The TCP DNS length prefix" block to TcpDnsClientSpec covering the helpers directly, which nothing previously did — they were only exercised indirectly through the client's receive loop:

  • round trip over every representable length, 0 to 65535
  • the expected big endian byte layout at the boundaries (0, 1, 255, 256, 65535)
  • decoding of bytes with the high bit set (0xFF 0xFF → 65535, 0x80 0x80 → 32896) — the case the signed-byte workaround existed for
  • trailing bytes after the two byte prefix are ignored

These pass against both the old and the new implementation. That is the correct outcome for a behaviour preserving change, and worth stating plainly: unlike a bug fix, these tests do not fail on the unmodified code. Their value is locking the contract down so a future change to this framing cannot silently break it.

actor-tests/testOnly org.apache.pekko.io.dns.internal.TcpDnsClientSpec — 8 tests, all pass. sbt actor/mimaReportBinaryIssues clean; TcpDnsClient is private[internal], so no API surface is affected. scalafmt run on both modules.

References

Found during a sweep for patterns left over from the Java 8 / Scala 2.12 baseline, alongside #3465.

RFC 1035 section 4.2.2 prefixes each DNS over TCP message with its
length as two big endian bytes. Both directions expressed this with
arithmetic rather than bit operations:

  encodeLength: ByteString((length / 256).toByte, length.toByte)
  decodeLength: ((data(0).toInt + 256) % 256) * 256 + ((data(1) + 256) % 256)

The `+ 256) % 256` pairs are working around Byte being signed, which
`& 0xFF` says directly, and the division and multiplication are a shift.

This is behaviour preserving, not a fix. `decodeLength` was verified
identical over all 65536 byte pairs, and `encodeLength` over the whole
valid length range 0 to 65535. The two forms diverge only for negative
lengths, which cannot occur: the only caller passes a ByteString length.

Adds tests for the prefix itself: a round trip over every representable
length, the expected big endian encoding, decoding of bytes with the
high bit set, and that trailing bytes after the prefix are ignored.
These pass against both the old and the new implementation, as a
behaviour preserving change requires.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant