diff --git a/localPackages/BitFoundation/Sources/BitFoundation/Data+Hex.swift b/localPackages/BitFoundation/Sources/BitFoundation/Data+Hex.swift index e847b2e2..cbebcc0c 100644 --- a/localPackages/BitFoundation/Sources/BitFoundation/Data+Hex.swift +++ b/localPackages/BitFoundation/Sources/BitFoundation/Data+Hex.swift @@ -8,12 +8,44 @@ import struct Foundation.Data +/// Lowercase hex digits used by `hexEncodedString()`. +private let hexDigits: [UInt8] = Array("0123456789abcdef".utf8) + +/// Maps an ASCII byte to its hex nibble value, or nil for non-hex characters. +/// Accepts both lowercase and uppercase hex digits. +@inline(__always) +private func hexNibble(_ ascii: UInt8) -> UInt8? { + switch ascii { + case UInt8(ascii: "0")...UInt8(ascii: "9"): + return ascii - UInt8(ascii: "0") + case UInt8(ascii: "a")...UInt8(ascii: "f"): + return ascii - UInt8(ascii: "a") + 10 + case UInt8(ascii: "A")...UInt8(ascii: "F"): + return ascii - UInt8(ascii: "A") + 10 + default: + return nil + } +} + public extension Data { + /// Lowercase hex representation of the bytes. + /// + /// Lookup-table based: this sits on the hot BLE receive path (it is called + /// several times per received packet via `PeerID(hexData:)`), where the + /// previous per-byte `String(format: "%02x", _)` implementation spent most + /// of its time re-parsing the format string through Foundation. func hexEncodedString() -> String { - if self.isEmpty { + if isEmpty { return "" } - return self.map { String(format: "%02x", $0) }.joined() + var output = [UInt8](repeating: 0, count: count * 2) + var i = 0 + for byte in self { + output[i] = hexDigits[Int(byte >> 4)] + output[i + 1] = hexDigits[Int(byte & 0x0F)] + i += 2 + } + return String(decoding: output, as: UTF8.self) } /// Initialize Data from a hex string. @@ -28,28 +60,28 @@ public extension Data { hex = String(hex.dropFirst(2)) } + let ascii = Array(hex.utf8) + // Reject odd-length strings - guard hex.count % 2 == 0 else { + guard ascii.count % 2 == 0 else { return nil } - // Reject empty strings - guard !hex.isEmpty else { + // Accept empty strings + guard !ascii.isEmpty else { self = Data() return } - let len = hex.count / 2 - var data = Data(capacity: len) - var index = hex.startIndex - - for _ in 0.. +// + +import Testing +import Foundation +@testable import BitFoundation + +struct DataHexTests { + + // MARK: - Encoding + + @Test func encode_knownVectors() { + #expect(Data().hexEncodedString() == "") + #expect(Data([0x00]).hexEncodedString() == "00") + #expect(Data([0x0f]).hexEncodedString() == "0f") + #expect(Data([0xf0]).hexEncodedString() == "f0") + #expect(Data([0xff]).hexEncodedString() == "ff") + #expect(Data([0xde, 0xad, 0xbe, 0xef]).hexEncodedString() == "deadbeef") + #expect(Data([0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]).hexEncodedString() == "0123456789abcdef") + } + + @Test func encode_allByteValues_matchesFormatReference() { + let all = Data((0...255).map { UInt8($0) }) + let reference = (0...255).map { String(format: "%02x", $0) }.joined() + #expect(all.hexEncodedString() == reference) + } + + @Test func encode_worksOnDataSlices() { + let data = Data([0xaa, 0xde, 0xad, 0xbe, 0xef, 0xbb]) + let slice = data.dropFirst().dropLast() + #expect(slice.hexEncodedString() == "deadbeef") + } + + // MARK: - Decoding + + @Test func decode_knownVectors() { + #expect(Data(hexString: "deadbeef") == Data([0xde, 0xad, 0xbe, 0xef])) + #expect(Data(hexString: "DEADBEEF") == Data([0xde, 0xad, 0xbe, 0xef])) + #expect(Data(hexString: "DeAdBeEf") == Data([0xde, 0xad, 0xbe, 0xef])) + #expect(Data(hexString: "00") == Data([0x00])) + #expect(Data(hexString: "0123456789abcdef") == Data([0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef])) + } + + @Test func decode_handlesPrefixAndWhitespace() { + #expect(Data(hexString: "0xdeadbeef") == Data([0xde, 0xad, 0xbe, 0xef])) + #expect(Data(hexString: "0XDEADBEEF") == Data([0xde, 0xad, 0xbe, 0xef])) + #expect(Data(hexString: " deadbeef\n") == Data([0xde, 0xad, 0xbe, 0xef])) + #expect(Data(hexString: "") == Data()) + #expect(Data(hexString: "0x") == Data()) + } + + @Test func decode_rejectsInvalidInput() { + #expect(Data(hexString: "abc") == nil) // odd length + #expect(Data(hexString: "zz") == nil) // non-hex characters + #expect(Data(hexString: "0xg1") == nil) // non-hex after prefix + #expect(Data(hexString: "+f") == nil) // sign characters are not hex + #expect(Data(hexString: "-0") == nil) + #expect(Data(hexString: "a\u{00e9}") == nil) // non-ASCII + #expect(Data(hexString: "de ad") == nil) // interior whitespace + } + + // MARK: - Round trip + + @Test func roundTrip_randomLengths() { + for length in [0, 1, 2, 3, 8, 16, 31, 32, 33, 64, 255, 1024] { + let data = Data((0..