From 6da7bc7f924bbf43f197f144dc4a70b86d69714e Mon Sep 17 00:00:00 2001 From: Richard Higgins Date: Wed, 22 Jul 2026 15:45:22 -0700 Subject: [PATCH] Add a bounds-checked binary cursor --- README.md | 1 + src/flatty/cursor.nim | 151 ++++++++++++++++++++++++++++++++++++++++++ tests/test_cursor.nim | 50 ++++++++++++++ tests/tests.nim | 2 +- 4 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 src/flatty/cursor.nim create mode 100644 tests/test_cursor.nim diff --git a/README.md b/README.md index 95377cb..3b44d62 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ This library has no dependencies other than the Nim standard library. * Aim of `flatty` is to be the fastest and simplest serializer/deserializer for Nim. * Also includes `hexprint` to print out binary data. * Also includes `binny` a simpler replacement for StringStream (no IO effects, operates on a string) +* Also includes `cursor` for bounds-checked sequential binary reads with catchable errors. * Also includes `hashy` a hash for any objects based on the flatty serializer. * Also includes `encode` a way to convert to/from utf16 BE/LE and with BOM and utf32. * Only Nim-owned values are serialized. Raw pointers, cstrings, procs, and distinct OS handles are rejected. diff --git a/src/flatty/cursor.nim b/src/flatty/cursor.nim new file mode 100644 index 0000000..2c626b5 --- /dev/null +++ b/src/flatty/cursor.nim @@ -0,0 +1,151 @@ +## Bounds-checked sequential reads over an in-memory binary blob. +## +## `binny` is ideal for fixed-offset primitive reads. `BinaryCursor` adds a +## small amount of state for parsers that consume fields in order and need +## catchable errors at an untrusted-input boundary. + +import flatty/binny + +type + BinaryCursorError* = object of CatchableError + + BinaryCursor* = object + data: string + position*: int + +proc fail(message: string) {.noreturn.} = + raise newException(BinaryCursorError, message) + +proc initBinaryCursor*(data: sink string; position = 0): BinaryCursor = + ## Creates a cursor over `data`, initially positioned at `position`. + if position < 0 or position > data.len: + fail("binary cursor position is outside the input") + BinaryCursor(data: move(data), position: position) + +proc len*(cursor: BinaryCursor): int {.inline.} = + ## Returns the total byte length of the cursor input. + cursor.data.len + +proc remaining*(cursor: BinaryCursor): int {.inline.} = + ## Returns the number of unread bytes. + cursor.data.len - cursor.position + +proc atEnd*(cursor: BinaryCursor): bool {.inline.} = + ## Returns true when no unread bytes remain. + cursor.position == cursor.data.len + +proc require*(cursor: BinaryCursor; count: int) = + ## Ensures that `count` bytes can be consumed from the current position. + if count < 0 or count > cursor.remaining: + fail( + "binary cursor needs " & $count & " bytes at " & $cursor.position & + ", but only " & $cursor.remaining & " remain" + ) + +proc seek*(cursor: var BinaryCursor; position: int) = + ## Moves the cursor to an absolute byte position. + if position < 0 or position > cursor.data.len: + fail("binary cursor seek is outside the input") + cursor.position = position + +proc skip*(cursor: var BinaryCursor; count: int) = + ## Advances the cursor by `count` bytes. + cursor.require(count) + cursor.position += count + +proc align*(cursor: var BinaryCursor; alignment: int) = + ## Advances to the next `alignment` byte boundary. + if alignment <= 0: + fail("binary cursor alignment must be positive") + let remainder = cursor.position mod alignment + if remainder != 0: + cursor.skip(alignment - remainder) + +proc readBytes*(cursor: var BinaryCursor; count: int): string = + ## Reads exactly `count` bytes. + cursor.require(count) + result = cursor.data[cursor.position ..< cursor.position + count] + cursor.position += count + +proc readCString*(cursor: var BinaryCursor; maxBytes = -1): string = + ## Reads a NUL-terminated string, optionally bounded by `maxBytes`. + if maxBytes < -1: + fail("binary cursor string bound cannot be negative") + let limit = + if maxBytes == -1: + cursor.data.len + else: + cursor.position + min(maxBytes, cursor.remaining) + var stop = cursor.position + while stop < limit and cursor.data[stop] != '\0': + inc stop + if stop == limit: + fail("binary cursor string is not NUL-terminated within its bound") + result = cursor.data[cursor.position ..< stop] + cursor.position = stop + 1 + +proc readSubcursor*(cursor: var BinaryCursor; count: int): BinaryCursor = + ## Reads a bounded region and returns a cursor over that region. + initBinaryCursor(cursor.readBytes(count)) + +proc readUint8*(cursor: var BinaryCursor): uint8 = + cursor.require(1) + result = cursor.data.readUint8(cursor.position) + inc cursor.position + +proc readUint16Le*(cursor: var BinaryCursor): uint16 = + cursor.require(2) + result = cursor.data.readUint16(cursor.position) + cursor.position += 2 + +proc readUint16Be*(cursor: var BinaryCursor): uint16 = + cursor.readUint16Le().swap() + +proc readUint32Le*(cursor: var BinaryCursor): uint32 = + cursor.require(4) + result = cursor.data.readUint32(cursor.position) + cursor.position += 4 + +proc readUint32Be*(cursor: var BinaryCursor): uint32 = + cursor.readUint32Le().swap() + +proc readUint64Le*(cursor: var BinaryCursor): uint64 = + cursor.require(8) + result = cursor.data.readUint64(cursor.position) + cursor.position += 8 + +proc readUint64Be*(cursor: var BinaryCursor): uint64 = + cursor.readUint64Le().swap() + +proc readInt8*(cursor: var BinaryCursor): int8 = + cast[int8](cursor.readUint8()) + +proc readInt16Le*(cursor: var BinaryCursor): int16 = + cast[int16](cursor.readUint16Le()) + +proc readInt16Be*(cursor: var BinaryCursor): int16 = + cast[int16](cursor.readUint16Be()) + +proc readInt32Le*(cursor: var BinaryCursor): int32 = + cast[int32](cursor.readUint32Le()) + +proc readInt32Be*(cursor: var BinaryCursor): int32 = + cast[int32](cursor.readUint32Be()) + +proc readInt64Le*(cursor: var BinaryCursor): int64 = + cast[int64](cursor.readUint64Le()) + +proc readInt64Be*(cursor: var BinaryCursor): int64 = + cast[int64](cursor.readUint64Be()) + +proc readFloat32Le*(cursor: var BinaryCursor): float32 = + cast[float32](cursor.readUint32Le()) + +proc readFloat32Be*(cursor: var BinaryCursor): float32 = + cast[float32](cursor.readUint32Be()) + +proc readFloat64Le*(cursor: var BinaryCursor): float64 = + cast[float64](cursor.readUint64Le()) + +proc readFloat64Be*(cursor: var BinaryCursor): float64 = + cast[float64](cursor.readUint64Be()) diff --git a/tests/test_cursor.nim b/tests/test_cursor.nim new file mode 100644 index 0000000..ee50035 --- /dev/null +++ b/tests/test_cursor.nim @@ -0,0 +1,50 @@ +import std/strutils + +import flatty/cursor + +block: + var cursor = initBinaryCursor( + "\x12\x34\x56\x78\x00\x00\x50\x40hello\x00tail" + ) + doAssert cursor.readUint8() == 0x12'u8 + doAssert cursor.readUint16Be() == 0x3456'u16 + doAssert cursor.readUint8() == 0x78'u8 + doAssert cursor.readFloat32Le() == 3.25'f32 + doAssert cursor.readCString() == "hello" + doAssert cursor.readBytes(4) == "tail" + doAssert cursor.atEnd + +block: + var cursor = initBinaryCursor("\x01\x02\x03\x04payload") + let header = cursor.readSubcursor(4) + var readableHeader = header + doAssert readableHeader.readUint32Le() == 0x04030201'u32 + doAssert readableHeader.atEnd + doAssert cursor.remaining == 7 + cursor.seek(4) + cursor.align(4) + doAssert cursor.readBytes(7) == "payload" + +block: + var cursor = initBinaryCursor("abc") + try: + discard cursor.readUint32Le() + doAssert false + except BinaryCursorError as error: + doAssert "only 3 remain" in error.msg + +block: + var cursor = initBinaryCursor("unterminated") + try: + discard cursor.readCString(5) + doAssert false + except BinaryCursorError as error: + doAssert "not NUL-terminated" in error.msg + +block: + var cursor = initBinaryCursor("1234") + try: + cursor.align(0) + doAssert false + except BinaryCursorError: + discard diff --git a/tests/tests.nim b/tests/tests.nim index e0359d6..88de145 100644 --- a/tests/tests.nim +++ b/tests/tests.nim @@ -1,4 +1,4 @@ -import test_binny, test_datetime, test_flatty, test_hashy, test_hexprint, +import test_binny, test_cursor, test_datetime, test_flatty, test_hashy, test_hexprint, test_issue_objects, test_memoryused, test_modes, test_objvar echo "all tests pass"