From e71b69a9701e59cbe33db71ee20971e81061569f Mon Sep 17 00:00:00 2001 From: krishna28238-arch <319297638+krishna28238-arch@users.noreply.github.com> Date: Tue, 22 Sep 2026 05:16:53 +0000 Subject: [PATCH] [Swift] Harden input validation in FlatBuffers & FlexBuffers runtime readers Fixes several out-of-bounds read paths and correctness issues that are reachable when reading malformed buffers through the non-verifying APIs: FlatBuffers: - Table.compare (two-offset variants) iterated one byte past the shared length of the compared strings and its length-only fallthrough was inverted relative to the loop's (second - first) convention. - ByteBuffer.readString trapped or read out of bounds on negative / out-of-range counts since its bounds check was assert-only (stripped in release); it now returns nil. readSlice and withUnsafePointerToSlice now use preconditions so release builds fail deterministically instead of reading out of bounds. - String(bb, o:) read its length prefix without checking the offset against the buffer; corrupted offsets now resolve to an empty string. - getRoot / getPrefixedSizeRoot failed with an unhelpful unwrap trap or read out of bounds on buffers too small to hold a root offset; they now fail fast with a descriptive message. FlexBuffers: - FlexBuffersWriter stored String.count (grapheme clusters) as the byte length of strings and map keys while writing UTF-8 bytes, truncating non-ASCII strings and breaking sorted-map lookups for non-ASCII keys; lengths are now UTF-8 byte counts. - Value.elementWidth searched byte widths 1, 3, 5, 7 instead of 1, 2, 4, 8, so every offset wider than a byte fell back to w64; the search now uses power-of-two widths matching the C++ implementation. - binarySearch over map keys probed index == count when the target was greater than every key, reading past the keys vector. - getRoot accepted any byte width (0-255) from the trailing root byte, computing a negative root offset for large widths; invalid widths now throw FlexBuffersErrors.invalidByteWidth. - TypedVector.mapKeys propagated a corrupted child byte width (also a numericCast overflow trap); structurally invalid key vectors are now treated as empty. - TypedVector.compare used strcmp against an unchecked raw pointer, reading past the end of the buffer for non-null-terminated keys; the comparison is now bounded by the buffer size. Adds regression tests for each fix under tests/swift/Tests. --- swift/Sources/FlatBuffers/ByteBuffer.swift | 20 +-- swift/Sources/FlatBuffers/Root.swift | 16 +- .../FlatBuffers/String+extension.swift | 4 + swift/Sources/FlatBuffers/Table.swift | 8 +- swift/Sources/FlexBuffers/ByteBuffer.swift | 20 +-- .../FlexBuffers/Reader/Reference.swift | 9 ++ .../FlexBuffers/Reader/TypedVector.swift | 62 +++++++- swift/Sources/FlexBuffers/Utils/Value.swift | 2 +- .../Sources/FlexBuffers/Utils/functions.swift | 7 +- .../Writer/FlexBuffersWriter.swift | 6 +- .../FlatbuffersInputValidationTests.swift | 118 +++++++++++++++ .../FlexBuffersInputValidationTests.swift | 143 ++++++++++++++++++ 12 files changed, 378 insertions(+), 37 deletions(-) create mode 100644 tests/swift/Tests/Flatbuffers/FlatbuffersInputValidationTests.swift create mode 100644 tests/swift/Tests/Flexbuffers/FlexBuffersInputValidationTests.swift diff --git a/swift/Sources/FlatBuffers/ByteBuffer.swift b/swift/Sources/FlatBuffers/ByteBuffer.swift index f22ecff571..ec4cf73f49 100644 --- a/swift/Sources/FlatBuffers/ByteBuffer.swift +++ b/swift/Sources/FlatBuffers/ByteBuffer.swift @@ -370,8 +370,8 @@ public struct ByteBuffer: @unchecked Sendable { index: Int, count: Int) -> [T] { - assert( - index + count <= capacity, + precondition( + index >= 0 && count >= 0 && index <= capacity && count <= capacity - index, "Reading out of bounds is illegal") return _storage.readWithUnsafeRawPointer(position: index) { @@ -393,8 +393,8 @@ public struct ByteBuffer: @unchecked Sendable { count: Int, body: (UnsafeRawBufferPointer) throws -> T) rethrows -> T { - assert( - index + count <= capacity, + precondition( + index >= 0 && count >= 0 && index <= capacity && count <= capacity - index, "Reading out of bounds is illegal") return try _storage.readWithUnsafeRawPointer(position: index) { try body(UnsafeRawBufferPointer(start: $0, count: count)) @@ -413,9 +413,9 @@ public struct ByteBuffer: @unchecked Sendable { count: Int, type: String.Encoding = .utf8) -> String? { - assert( - index + count <= capacity, - "Reading out of bounds is illegal") + guard index >= 0, count >= 0, index <= capacity, count <= capacity - index else { + return nil + } return _storage.readWithUnsafeRawPointer(position: index) { let buf = UnsafeBufferPointer( start: $0.bindMemory(to: UInt8.self, capacity: count), @@ -435,9 +435,9 @@ public struct ByteBuffer: @unchecked Sendable { at index: Int, count: Int) -> String? { - assert( - index + count <= capacity, - "Reading out of bounds is illegal") + guard index >= 0, count >= 0, index <= capacity, count <= capacity - index else { + return nil + } return _storage.readWithUnsafeRawPointer(position: index) { String(cString: $0.bindMemory(to: UInt8.self, capacity: count)) } diff --git a/swift/Sources/FlatBuffers/Root.swift b/swift/Sources/FlatBuffers/Root.swift index 807f7b4efa..f0a508ee73 100644 --- a/swift/Sources/FlatBuffers/Root.swift +++ b/swift/Sources/FlatBuffers/Root.swift @@ -103,6 +103,9 @@ public func getPrefixedSizeRoot( byteBuffer: inout ByteBuffer) -> T { + guard byteBuffer.size >= MemoryLayout.size else { + fatalError("ByteBuffer is too small to hold a size prefix") + } byteBuffer.skipPrefix() return getRoot(byteBuffer: &byteBuffer) @@ -139,8 +142,13 @@ public func getCheckedRoot( /// - Parameter byteBuffer: Buffer that contains data /// - Returns: Returns a Flatbuffers object public func getRoot(byteBuffer: inout ByteBuffer) -> T { - T.init( - byteBuffer, - o: Int32(byteBuffer.read(def: UOffset.self, position: byteBuffer.reader)) - &+ Int32(byteBuffer.reader)) + let reader = byteBuffer.reader + guard reader &+ MemoryLayout.size <= byteBuffer.capacity else { + fatalError("ByteBuffer is too small to hold a root offset") + } + let position = reader &+ Int(byteBuffer.read(def: UOffset.self, position: reader)) + guard position &+ MemoryLayout.size <= byteBuffer.capacity else { + fatalError("Root offset points outside of the ByteBuffer bounds") + } + return T.init(byteBuffer, o: Int32(position)) } diff --git a/swift/Sources/FlatBuffers/String+extension.swift b/swift/Sources/FlatBuffers/String+extension.swift index 6c34196653..ea3eeb5b9f 100644 --- a/swift/Sources/FlatBuffers/String+extension.swift +++ b/swift/Sources/FlatBuffers/String+extension.swift @@ -63,6 +63,10 @@ extension String: FlatbuffersInitializable { /// - o: Current position public init(_ bb: ByteBuffer, o: Int32) { let v = Int(o) + guard v >= 0, v &+ MemoryLayout.size <= bb.capacity else { + self = "" + return + } let count = bb.read(def: Int32.self, position: v) self = bb.readString( diff --git a/swift/Sources/FlatBuffers/Table.swift b/swift/Sources/FlatBuffers/Table.swift index de87626549..3210c326c3 100644 --- a/swift/Sources/FlatBuffers/Table.swift +++ b/swift/Sources/FlatBuffers/Table.swift @@ -230,14 +230,14 @@ public struct Table { let startPos1 = _off1 &+ memorySize let startPos2 = _off2 &+ memorySize let minValue = min(len1, len2) - for i in 0...minValue { + for i in 0.. [T] { - assert( - index + count <= capacity, + precondition( + index >= 0 && count >= 0 && index <= capacity && count <= capacity - index, "Reading out of bounds is illegal") return _storage.readWithUnsafeRawPointer(position: index) { @@ -429,9 +429,9 @@ public struct ByteBuffer { count: Int, type: String.Encoding) -> String? { - assert( - index + count <= capacity, - "Reading out of bounds is illegal") + guard index >= 0, count >= 0, index <= capacity, count <= capacity - index else { + return nil + } return _storage.readWithUnsafeRawPointer(position: index) { let buf = UnsafeBufferPointer( start: $0.bindMemory(to: UInt8.self, capacity: count), @@ -451,9 +451,9 @@ public struct ByteBuffer { at index: Int, count: Int) -> String? { - assert( - index + count <= capacity, - "Reading out of bounds is illegal") + guard index >= 0, count >= 0, index <= capacity, count <= capacity - index else { + return nil + } return _storage.readWithUnsafeRawPointer(position: index) { String(cString: $0.bindMemory(to: UInt8.self, capacity: count)) } @@ -470,8 +470,8 @@ public struct ByteBuffer { count: Int, body: (UnsafeRawBufferPointer) throws -> T) rethrows -> T { - assert( - index + count <= capacity, + precondition( + index >= 0 && count >= 0 && index <= capacity && count <= capacity - index, "Reading out of bounds is illegal") return try _storage.readWithUnsafeRawPointer(position: index) { try body(UnsafeRawBufferPointer(start: $0, count: count)) diff --git a/swift/Sources/FlexBuffers/Reader/Reference.swift b/swift/Sources/FlexBuffers/Reader/Reference.swift index e78efecce7..6998071dc4 100644 --- a/swift/Sources/FlexBuffers/Reader/Reference.swift +++ b/swift/Sources/FlexBuffers/Reader/Reference.swift @@ -20,6 +20,7 @@ import Foundation enum FlexBuffersErrors: Error { case sizeOfBufferIsTooSmall case typeCouldNotBeDetermined + case invalidByteWidth(width: UInt8) } @inline(__always) @@ -35,7 +36,15 @@ public func getRoot(buffer: ByteBuffer) throws -> Reference? { let byteWidth = buffer.read(def: UInt8.self, position: end &- 1) let packedType = buffer.read(def: UInt8.self, position: end &- 2) + + guard isValidByteWidth(byteWidth) else { + throw FlexBuffersErrors.invalidByteWidth(width: byteWidth) + } + let offset = end &- 2 &- numericCast(byteWidth) + guard offset >= 0 else { + throw FlexBuffersErrors.sizeOfBufferIsTooSmall + } return Reference( byteBuffer: buffer, diff --git a/swift/Sources/FlexBuffers/Reader/TypedVector.swift b/swift/Sources/FlexBuffers/Reader/TypedVector.swift index 45880a96c4..975075abf7 100644 --- a/swift/Sources/FlexBuffers/Reader/TypedVector.swift +++ b/swift/Sources/FlexBuffers/Reader/TypedVector.swift @@ -30,13 +30,15 @@ public struct TypedVector: FlexBufferVector { byteBuffer: ByteBuffer, offset: Int, byteWidth: UInt8, - type: FlexBufferType) + type: FlexBufferType, + count providedCount: Int? = nil) { self.byteBuffer = byteBuffer self.offset = offset self.byteWidth = byteWidth self.type = type - count = getCount(buffer: byteBuffer, offset: offset, byteWidth: byteWidth) + count = providedCount + ?? getCount(buffer: byteBuffer, offset: offset, byteWidth: byteWidth) } @inline(__always) @@ -68,15 +70,38 @@ public struct TypedVector: FlexBufferVector { offset: keysOffset &+ numericCast(byteWidth), byteWidth: byteWidth) + // A corrupted buffer may report a child byte width that is not one of + // the valid flexbuffers widths (1, 2, 4, 8), or a keys location that + // lies outside of the buffer. Treat such a map as if it had no + // readable keys, instead of reading out of bounds. + guard + let width = UInt8(exactly: childByteWidth), + isValidByteWidth(width), + indirectOffset >= numericCast(width), + indirectOffset <= byteBuffer.count + else { + return TypedVector( + byteBuffer: byteBuffer, + offset: offset, + byteWidth: 1, + type: .key, + count: 0) + } + return TypedVector( byteBuffer: byteBuffer, offset: indirectOffset, - byteWidth: numericCast(childByteWidth), + byteWidth: width, type: .key) } } extension TypedVector { + /// Compares a key within the vector to a target string. + /// + /// The comparison is bounded by the end of the hosting buffer so that a + /// corrupted buffer without a string terminator cannot cause an + /// out-of-bounds read past the end of the buffer. @inline(__always) func compare(offset off: Int, target: String) -> Int { @@ -87,12 +112,41 @@ extension TypedVector { offset: elementOffset, byteWidth) + guard indirectoffset >= 0, indirectoffset < byteBuffer.capacity else { + return -1 + } + + let maxLength = byteBuffer.capacity &- indirectoffset return byteBuffer.readWithUnsafeRawPointer( position: indirectoffset) { bufPointer in target.withCString { strPointer in - Int(strcmp(bufPointer, strPointer)) + boundedStrcmp(bufPointer, strPointer, maxLength: maxLength) } } } } + +@inline(__always) +private func boundedStrcmp( + _ lhs: UnsafeRawPointer, + _ rhs: UnsafePointer, + maxLength: Int) -> Int +{ + var i = 0 + while i < maxLength { + let l = UInt8(bitPattern: lhs.load(fromByteOffset: i, as: Int8.self)) + let r = UInt8(bitPattern: rhs[i]) + if l != r { + return Int(l) &- Int(r) + } + if l == 0 { + return 0 + } + i &+= 1 + } + // The readable bytes ran out before a terminator or a difference was + // found: the target is either equal (it ended as well) or longer than + // the readable bytes. + return rhs[i] == 0 ? 0 : -1 +} diff --git a/swift/Sources/FlexBuffers/Utils/Value.swift b/swift/Sources/FlexBuffers/Utils/Value.swift index 36bda4a73f..ff7ffa8287 100644 --- a/swift/Sources/FlexBuffers/Utils/Value.swift +++ b/swift/Sources/FlexBuffers/Utils/Value.swift @@ -109,7 +109,7 @@ extension Value { if isInline(type) { return bitWidth } else { - for byteWidth in stride(from: 1, to: MemoryLayout.size, by: 2) { + for byteWidth in [1, 2, 4, 8] { let _offsetLoc: UInt64 = numericCast( numericCast(size) &+ padding( diff --git a/swift/Sources/FlexBuffers/Utils/functions.swift b/swift/Sources/FlexBuffers/Utils/functions.swift index c33d49311b..e4f6b53ac7 100644 --- a/swift/Sources/FlexBuffers/Utils/functions.swift +++ b/swift/Sources/FlexBuffers/Utils/functions.swift @@ -140,13 +140,18 @@ func toFixedTypedVectorElementType(type: FlexBufferType) // MARK: - Reader functions +@inline(__always) +func isValidByteWidth(_ width: UInt8) -> Bool { + width == 1 || width == 2 || width == 4 || width == 8 +} + @inline(__always) func binarySearch( vector: TypedVector, target: String) -> Int? { var left = 0 - var right = vector.count + var right = vector.count &- 1 while left <= right { let mid = left &+ (right &- left) / 2 diff --git a/swift/Sources/FlexBuffers/Writer/FlexBuffersWriter.swift b/swift/Sources/FlexBuffers/Writer/FlexBuffersWriter.swift index 77a4db7516..fd610875f4 100644 --- a/swift/Sources/FlexBuffers/Writer/FlexBuffersWriter.swift +++ b/swift/Sources/FlexBuffers/Writer/FlexBuffersWriter.swift @@ -479,12 +479,12 @@ public struct FlexBuffersWriter { @inline(__always) public mutating func add(string: borrowing String, key: borrowing String) { add(key: key) - write(str: string, len: string.count) + write(str: string, len: string.utf8.count) } @inline(__always) public mutating func add(string: borrowing String) { - write(str: string, len: string.count) + write(str: string, len: string.utf8.count) } // MARK: - Writing Blobs @@ -591,7 +591,7 @@ public struct FlexBuffersWriter { @discardableResult @inline(__always) private mutating func add(key: borrowing String) -> UInt { - add(key: key, len: key.count) + add(key: key, len: key.utf8.count) } @discardableResult diff --git a/tests/swift/Tests/Flatbuffers/FlatbuffersInputValidationTests.swift b/tests/swift/Tests/Flatbuffers/FlatbuffersInputValidationTests.swift new file mode 100644 index 0000000000..b285f983f9 --- /dev/null +++ b/tests/swift/Tests/Flatbuffers/FlatbuffersInputValidationTests.swift @@ -0,0 +1,118 @@ +/* + * Copyright 2026 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation +import Testing + +import FlatBuffers + +struct FlatbuffersInputValidationTests { + + // MARK: - Table.compare + + /// Builds a buffer hosting two length-prefixed, null-terminated strings + /// addressed by two uoffset slots (the layout generated code uses for + /// keyed vectors of tables). + private func compareBuffer(_ a: String, _ b: String) -> ByteBuffer { + let aBytes = Array(a.utf8) + let bBytes = Array(b.utf8) + var bytes: [UInt8] = [] + func appendUInt32(_ value: UInt32) { + withUnsafeBytes(of: value.littleEndian) { bytes.append(contentsOf: $0) } + } + + let stringAPosition = 8 + let stringBPosition = stringAPosition + 4 + aBytes.count + 1 + + // uoffset slots store the forward distance to their target. + appendUInt32(UInt32(stringAPosition)) + appendUInt32(UInt32(stringBPosition - 4)) + appendUInt32(UInt32(aBytes.count)) + bytes.append(contentsOf: aBytes) + bytes.append(0) + appendUInt32(UInt32(bBytes.count)) + bytes.append(contentsOf: bBytes) + bytes.append(0) + return ByteBuffer(bytes: bytes) + } + + @Test( + "Table.compare orders strings by their UTF-8 bytes", + arguments: [ + ("abc", "abc"), + ("abc", "abd"), + ("abc", "ab"), + ("ab", "abc"), + ("", ""), + ("", "a"), + ("a", ""), + ("a", "b"), + ("b", "a"), + ("a\0b", "a\0c"), + ("a\0b", "a\0b"), + ]) + func testTableCompareOrdersByUTF8Bytes(a: String, b: String) { + let bb = compareBuffer(a, b) + let aPrecedes = Array(a.utf8).lexicographicallyPrecedes(Array(b.utf8)) + let equal = a == b + + // The two-offset variant returns (second - first). + let twoOffsets = Table.compare(0, 4, fbb: bb) + #expect((twoOffsets > 0) == aPrecedes) + #expect((twoOffsets == 0) == equal) + #expect((twoOffsets < 0) == (!aPrecedes && !equal)) + + // The key variant returns (buffer - key). + let keyVariant = Table.compare(0, Array(b.utf8), fbb: bb) + #expect((keyVariant < 0) == aPrecedes) + #expect((keyVariant == 0) == equal) + #expect((keyVariant > 0) == (!aPrecedes && !equal)) + } + + // MARK: - ByteBuffer bounds + + @Test + func testReadStringOutOfBoundsReturnsNil() { + let bb = ByteBuffer(bytes: Array("abc".utf8)) + #expect(bb.readString(at: 0, count: 3) == "abc") + #expect(bb.readString(at: 0, count: 4) == nil) + #expect(bb.readString(at: 2, count: 2) == nil) + #expect(bb.readString(at: 0, count: -1) == nil) + #expect(bb.readString(at: -1, count: 1) == nil) + } + + @Test + func testReadSliceStillReadsInBounds() { + let bb = ByteBuffer(bytes: [1, 0, 0, 0, 7, 0, 0, 0]) + let values: [Int32] = bb.readSlice(index: 0, count: 2) + #expect(values == [1, 7]) + } + + // MARK: - Corrupted string offsets + + @Test + func testStringInitWithCorruptedOffsetsIsEmpty() { + let bb = ByteBuffer(bytes: Array("abc".utf8)) + // A string pointing outside of the buffer resolves to "" instead of + // reading out of bounds. + #expect(String(bb, o: 100) == "") + #expect(String(bb, o: -5) == "") + #expect(String(bb, o: 1) == "") + // In-bounds offset with a length that exceeds the buffer also + // resolves to "". + #expect(String(bb, o: 0) == "") + } +} diff --git a/tests/swift/Tests/Flexbuffers/FlexBuffersInputValidationTests.swift b/tests/swift/Tests/Flexbuffers/FlexBuffersInputValidationTests.swift new file mode 100644 index 0000000000..baacd2b93a --- /dev/null +++ b/tests/swift/Tests/Flexbuffers/FlexBuffersInputValidationTests.swift @@ -0,0 +1,143 @@ +/* + * Copyright 2026 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation +import Testing + +@testable import FlexBuffers + +struct FlexBuffersInputValidationTests { + + // MARK: - Writer: UTF-8 byte lengths + + @Test + func testUnicodeStringsRoundTrip() throws { + var flx = FlexBuffersWriter() + flx.vector { + $0.add(string: "héllo") + $0.add(string: "日本語") + $0.add(string: "a🎉b") + } + flx.finish() + + let reference = try getRoot(buffer: flx.byteBuffer)! + let vector = reference.vector! + #expect(vector.count == 3) + #expect(vector[0]?.string() == "héllo") + #expect(vector[1]?.string() == "日本語") + #expect(vector[2]?.string() == "a🎉b") + } + + @Test + func testUnicodeMapKeysRoundTrip() throws { + var flx = FlexBuffersWriter() + flx.map { + $0.add(string: "café au lait", key: "café") + $0.add(int32: 42, key: "日本語") + } + flx.finish() + + let reference = try getRoot(buffer: flx.byteBuffer)! + let map = reference.map! + #expect(map.count == 2) + #expect(map["café"]?.string() == "café au lait") + #expect(map["日本語"]?.int == 42) + #expect(map["zzzz"] == nil) + #expect(map[""] == nil) + } + + // MARK: - Reader: map lookups stay within the keys vector + + @Test + func testMapLookupWithTargetOutsideOfKeyRange() throws { + var flx = FlexBuffersWriter() + flx.map { + $0.add(int32: 1, key: "a") + $0.add(int32: 2, key: "b") + $0.add(int32: 3, key: "c") + } + flx.finish() + + let reference = try getRoot(buffer: flx.byteBuffer)! + let map = reference.map! + #expect(map["a"]?.int == 1) + #expect(map["b"]?.int == 2) + #expect(map["c"]?.int == 3) + // Looking up a key greater than every stored key used to probe one + // element past the end of the keys vector. + #expect(map["zzz"] == nil) + #expect(map["0"] == nil) + } + + // MARK: - Reader: structural validation + + @Test + func testGetRootRejectsInvalidByteWidth() { + // The last byte of a flexbuffer encodes the root byte width; widths + // other than 1, 2, 4, 8 are structurally invalid. + #expect(throws: FlexBuffersErrors.self) { + _ = try getRoot(buffer: ByteBuffer(bytes: [0, 0, 0, 0, 0, 3])) + } + #expect(throws: FlexBuffersErrors.self) { + _ = try getRoot(buffer: ByteBuffer(bytes: [0, 0, 0])) + } + } + + @Test + func testGetRootRejectsByteWidthLargerThanBuffer() { + // A root byte width of 8 cannot be addressed by a 3 byte buffer. + #expect(throws: FlexBuffersErrors.self) { + _ = try getRoot(buffer: ByteBuffer(bytes: [0, 0, 8])) + } + } + + @Test + func testGetRootStillReadsValidBuffers() throws { + var flx = FlexBuffersWriter() + flx.map { + $0.add(bool: true, key: "ok") + } + flx.finish() + + let reference = try getRoot(buffer: flx.byteBuffer)! + #expect(reference.map?["ok"]?.bool == true) + } + + @Test + func testReadStringOutOfBoundsReturnsNil() { + let bb = ByteBuffer(bytes: Array("abc".utf8)) + #expect(bb.readString(at: 0, count: 3, type: .utf8) == "abc") + #expect(bb.readString(at: 0, count: 4, type: .utf8) == nil) + #expect(bb.readString(at: 0, count: -1, type: .utf8) == nil) + #expect(bb.readString(at: 0, count: 4) == nil) + } + + // MARK: - Value.elementWidth + + @Test + func testElementWidthUsesPowerOfTwoByteWidths() { + // A value stored 1000 bytes away from the current write location fits + // into a 16-bit offset, so elementWidth has to resolve to .w16. The + // width search previously iterated over 1, 3, 5, 7 instead of + // 1, 2, 4, 8, which made every offset wider than a byte fall back + // to .w64. + let wide = Value(v: UInt64(1000), type: .indirectUInt, bitWidth: .w8) + #expect(wide.elementWidth(size: 2000, index: 0) == .w16) + + let small = Value(v: UInt64(5), type: .indirectUInt, bitWidth: .w8) + #expect(small.elementWidth(size: 10, index: 1) == .w8) + } +}