Coverage Report

Created: 2026-06-01 06:32

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/src/swift-nio/Sources/NIOCore/ByteBuffer-hex.swift
Line
Count
Source
1
//===----------------------------------------------------------------------===//
2
//
3
// This source file is part of the SwiftNIO open source project
4
//
5
// Copyright (c) 2023 Apple Inc. and the SwiftNIO project authors
6
// Licensed under Apache License v2.0
7
//
8
// See LICENSE.txt for license information
9
// See CONTRIBUTORS.txt for the list of SwiftNIO project authors
10
//
11
// SPDX-License-Identifier: Apache-2.0
12
//
13
//===----------------------------------------------------------------------===//
14
15
extension ByteBuffer {
16
17
    /// Create a fresh `ByteBuffer` containing the `bytes` decoded from the string representation of `plainHexEncodedBytes`.
18
    ///
19
    /// This will allocate a new `ByteBuffer` with enough space to fit the hex decoded `bytes` and potentially some extra space
20
    /// using the default allocator.
21
    ///
22
    /// - info: If you have access to a `Channel`, `ChannelHandlerContext`, or `ByteBufferAllocator` we
23
    ///         recommend using `channel.allocator.buffer(integer:)`. Or if you want to write multiple items into the
24
    ///         buffer use `channel.allocator.buffer(capacity: ...)` to allocate a `ByteBuffer` of the right
25
    ///         size followed by a `writeHexEncodedBytes` instead of using this method. This allows SwiftNIO to do
26
    ///         accounting and optimisations of resources acquired for operations on a given `Channel` in the future.
27
0
    public init(plainHexEncodedBytes string: String) throws {
28
0
        self = try ByteBufferAllocator().buffer(plainHexEncodedBytes: string)
29
0
    }
30
31
    /// Describes a ByteBuffer hexDump format.
32
    /// Can be either xxd output compatible, or hexdump compatible.
33
    public struct HexDumpFormat: Hashable, Sendable {
34
35
        @usableFromInline
36
        enum Value: Hashable, Sendable {
37
            case plain(maxBytes: Int? = nil)
38
            case detailed(maxBytes: Int? = nil)
39
            case compact(maxBytes: Int? = nil)
40
        }
41
42
        @usableFromInline
43
        let value: Value
44
45
        @inlinable
46
0
        init(_ value: Value) { self.value = value }
47
48
        /// A plain hex dump format compatible with `xxd` CLI utility.
49
        @inlinable
50
0
        public static var plain: HexDumpFormat { Self(.plain(maxBytes: nil)) }
51
52
        /// A hex dump format compatible with `hexdump` command line utility.
53
        @inlinable
54
0
        public static var detailed: HexDumpFormat { Self(.detailed(maxBytes: nil)) }
55
56
        /// A hex dump analog to `plain` format  but without whitespaces.
57
0
        public static var compact: HexDumpFormat { Self(.compact(maxBytes: nil)) }
58
59
        /// A detailed hex dump format compatible with `xxd`, clipped to `maxBytes` bytes dumped.
60
        /// This format will dump first `maxBytes / 2` bytes, and the last `maxBytes / 2` bytes, replacing the rest with " ... ".
61
0
        public static func plain(maxBytes: Int) -> Self {
62
0
            Self(.plain(maxBytes: maxBytes))
63
0
        }
64
65
        /// A hex dump format compatible with `hexdump` command line tool.
66
        /// This format will dump first `maxBytes / 2` bytes, and the last `maxBytes / 2` bytes, with a placeholder in between.
67
0
        public static func detailed(maxBytes: Int) -> Self {
68
0
            Self(.detailed(maxBytes: maxBytes))
69
0
        }
70
71
        /// A hex dump analog to `plain`format  but without whitespaces.
72
        /// This format will dump first `maxBytes / 2` bytes, and the last `maxBytes / 2` bytes, with a placeholder in between.
73
0
        public static func compact(maxBytes: Int) -> Self {
74
0
            Self(.compact(maxBytes: maxBytes))
75
0
        }
76
    }
77
78
    /// Shared logic for `hexDumpPlain` and `hexDumpCompact`.
79
    /// Returns a `String` of hexadecimals digits of the readable bytes in the buffer.
80
    /// - Parameter
81
    ///   - separateWithWhitespace: Controls whether the hex deump will be separated by whitespaces.
82
0
    private func _hexDump(separateWithWhitespace: Bool) -> String {
83
0
        var hexString = ""
84
0
        var capacity: Int
85
0
86
0
        if separateWithWhitespace {
87
0
            capacity = self.readableBytes * 3
88
0
        } else {
89
0
            capacity = self.readableBytes * 2
90
0
        }
91
0
92
0
        hexString.reserveCapacity(capacity)
93
0
94
0
        for byte in self.readableBytesView {
95
0
            hexString += String(byte, radix: 16, padding: 2)
96
0
            if separateWithWhitespace {
97
0
                hexString += " "
98
0
            }
99
0
        }
100
0
101
0
        if separateWithWhitespace {
102
0
            return String(hexString.dropLast())
103
0
        }
104
0
105
0
        return hexString
106
0
    }
107
108
    /// Shared logic for `hexDumpPlain(maxBytes: Int)` and `hexDumpCompact(maxBytes: Int)`.
109
    ///
110
    /// - Parameters:
111
    ///   - maxBytes: The maximum amount of bytes presented in the dump.
112
    ///   - separateWithWhitespace: Controls whether the dump will be separated by whitespaces.
113
0
    private func _hexDump(maxBytes: Int, separateWithWhitespace: Bool) -> String {
114
0
        // If the buffer length fits in the max bytes limit in the hex dump, just dump the whole thing.
115
0
        if self.readableBytes <= maxBytes {
116
0
            return self._hexDump(separateWithWhitespace: separateWithWhitespace)
117
0
        }
118
0
119
0
        var buffer = self
120
0
121
0
        // Safe to force-unwrap because we just checked readableBytes is > maxBytes above.
122
0
        let front = buffer.readSlice(length: maxBytes / 2)!
123
0
        buffer.moveReaderIndex(to: buffer.writerIndex - maxBytes / 2)
124
0
        let back = buffer.readSlice(length: buffer.readableBytes)!
125
0
126
0
        let startHex = front._hexDump(separateWithWhitespace: separateWithWhitespace)
127
0
        let endHex = back._hexDump(separateWithWhitespace: separateWithWhitespace)
128
0
129
0
        var dots: String
130
0
        if separateWithWhitespace {
131
0
            dots = " ... "
132
0
        } else {
133
0
            dots = "..."
134
0
        }
135
0
136
0
        return startHex + dots + endHex
137
0
    }
138
139
    /// Return a `String` of space separated hexadecimal digits of the readable bytes in the buffer,
140
    /// in a format that's compatible with `xxd -r -p`.
141
    /// `hexDumpPlain()` always dumps all readable bytes, i.e. from `readerIndex` to `writerIndex`,
142
    /// so you should set those indices to desired location to get the offset and length that you need to dump.
143
0
    private func hexDumpPlain() -> String {
144
0
        self._hexDump(separateWithWhitespace: true)
145
0
    }
146
147
    /// Return a `String` of space delimited hexadecimal digits of the readable bytes in the buffer,
148
    /// in a format that's compatible with `xxd -r -p`, but clips the output to the max length of `maxBytes` bytes.
149
    /// If the dump contains more than the `maxBytes` bytes, this function will return the first `maxBytes/2`
150
    /// and the last `maxBytes/2` of that, replacing the rest with `...`, i.e. `01 02 03 ... 09 11 12`.
151
    ///
152
    /// - Parameters:
153
    ///   - maxBytes: The maximum amount of bytes presented in the dump.
154
0
    private func hexDumpPlain(maxBytes: Int) -> String {
155
0
        self._hexDump(maxBytes: maxBytes, separateWithWhitespace: true)
156
0
    }
157
158
    /// Return a `String` of  hexadecimal digits of the readable bytes in the buffer,
159
    /// analog to `.plain` format but without whitespaces. This format guarantees not to emit whitespaces.
160
    /// `hexDumpCompact()` always dumps all readable bytes, i.e. from `readerIndex` to `writerIndex`,
161
    /// so you should set those indices to desired location to get the offset and length that you need to dump.
162
0
    private func hexDumpCompact() -> String {
163
0
        self._hexDump(separateWithWhitespace: false)
164
0
    }
165
166
    /// Return a `String` of  hexadecimal digits of the readable bytes in the buffer,
167
    /// analog to `.plain` format but without whitespaces and clips the output to the max length of `maxBytes` bytes.
168
    /// This format guarantees not to emmit whitespaces.
169
    /// If the dump contains more than the `maxBytes` bytes, this function will return the first `maxBytes/2`
170
    /// and the last `maxBytes/2` of that, replacing the rest with `...`, i.e. `010203...091112`.
171
    ///
172
    /// - Parameters:
173
    ///   - maxBytes: The maximum amount of bytes presented in the dump.
174
0
    private func hexDumpCompact(maxBytes: Int) -> String {
175
0
        self._hexDump(maxBytes: maxBytes, separateWithWhitespace: false)
176
0
    }
177
178
    /// Returns a `String` containing a detailed hex dump of this buffer.
179
    /// Intended to be used internally in ``hexDump(format:)``
180
    /// - Parameters:
181
    ///   - lineOffset: an offset from the beginning of the outer buffer that is being dumped. It's used to print the line offset in hexdump -C format.
182
    ///   - paddingBefore: the amount of space to pad before the first byte dumped on this line, used in center and right columns.
183
    ///   - paddingAfter: the amount of sapce to pad after the last byte on this line, used in center and right columns.
184
0
    private func _hexDumpLine(lineOffset: Int, paddingBefore: Int = 0, paddingAfter: Int = 0) -> String {
185
0
        // Each line takes 78 visible characters + \n
186
0
        var result = ""
187
0
        result.reserveCapacity(79)
188
0
189
0
        // Left column of the hex dump signifies the offset from the beginning of the dumped buffer
190
0
        // and is separated from the next column with two spaces.
191
0
        result += String(lineOffset, radix: 16, padding: 8)
192
0
        result += "  "
193
0
194
0
        // Center column consists of:
195
0
        // - xxd-compatible dump of the first 8 bytes
196
0
        // - space
197
0
        // - xxd-compatible dump of the rest 8 bytes
198
0
        // If there are not enough bytes to dump, the column is padded with space.
199
0
200
0
        // If there's any padding on the left, apply that first.
201
0
        result += String(repeating: " ", count: paddingBefore * 3)
202
0
203
0
        // Add the left side of the central column
204
0
        let bytesInLeftColumn = max(8 - paddingBefore, 0)
205
0
        for byte in self.readableBytesView.prefix(bytesInLeftColumn) {
206
0
            result += String(byte, radix: 16, padding: 2)
207
0
            result += " "
208
0
        }
209
0
210
0
        // Add an extra space for the centre column.
211
0
        result += " "
212
0
213
0
        // Add the right side of the central column.
214
0
        for byte in self.readableBytesView.dropFirst(bytesInLeftColumn) {
215
0
            result += String(byte, radix: 16, padding: 2)
216
0
            result += " "
217
0
        }
218
0
219
0
        // Pad the resulting center column line to 60 characters.
220
0
        result += String(repeating: " ", count: 60 - result.count)
221
0
222
0
        // Right column renders the 16 bytes line as ASCII characters, or "." if the character is not printable.
223
0
        let printableRange = UInt8(ascii: " ")..<UInt8(ascii: "~")
224
0
        let printableBytes = self.readableBytesView.map {
225
0
            printableRange.contains($0) ? $0 : UInt8(ascii: ".")
226
0
        }
227
0
228
0
        result += "|"
229
0
        result += String(repeating: " ", count: paddingBefore)
230
0
        result += String(decoding: printableBytes, as: UTF8.self)
231
0
        result += String(repeating: " ", count: paddingAfter)
232
0
        result += "|\n"
233
0
        return result
234
0
    }
235
236
    /// Returns a `String` of hexadecimal digits of bytes in the Buffer,
237
    /// with formatting compatible with output of `hexdump -C`.
238
0
    private func hexdumpDetailed() -> String {
239
0
        if self.readableBytes == 0 {
240
0
            return ""
241
0
        }
242
0
243
0
        var result = ""
244
0
        result.reserveCapacity(self.readableBytes / 16 * 79 + 8)
245
0
246
0
        var buffer = self
247
0
248
0
        var lineOffset = 0
249
0
        while buffer.readableBytes > 0 {
250
0
            // Safe to force-unwrap because we're in a loop that guarantees there's at least one byte to read.
251
0
            let slice = buffer.readSlice(length: min(16, buffer.readableBytes))!
252
0
            result += slice._hexDumpLine(lineOffset: lineOffset)
253
0
            lineOffset += slice.readableBytes
254
0
        }
255
0
256
0
        result += String(self.readableBytes, radix: 16, padding: 8)
257
0
        return result
258
0
    }
259
260
    /// Returns a `String` of hexadecimal digits of bytes in this ByteBuffer
261
    /// with formatting sort of compatible with `hexdump -C`, but clipped on length.
262
    /// Dumps limit/2 first and limit/2 last bytes, with a separator line in between.
263
    ///
264
    /// - Parameters:
265
    ///   - maxBytes: Max bytes to dump.
266
0
    private func hexDumpDetailed(maxBytes: Int) -> String {
267
0
        if self.readableBytes <= maxBytes {
268
0
            return self.hexdumpDetailed()
269
0
        }
270
0
271
0
        let separator = "........  .. .. .. .. .. .. .. ..  .. .. .. .. .. .. .. ..  ..................\n"
272
0
273
0
        // reserve capacity for the maxBytes dumped, plus the separator line, and buffer length line.
274
0
        var result = ""
275
0
        result.reserveCapacity(maxBytes / 16 * 79 + 79 + 8)
276
0
277
0
        var buffer = self
278
0
279
0
        // Dump the front part of the buffer first, up to maxBytes/2 bytes.
280
0
        // Safe to force-unwrap because we know the buffer has more readable bytes than maxBytes.
281
0
        var front = buffer.readSlice(length: maxBytes / 2)!
282
0
        var bufferOffset = 0
283
0
        while front.readableBytes > 0 {
284
0
            // Safe to force-unwrap because buffer is guaranteed to have at least one byte in it.
285
0
            let slice = front.readSlice(length: min(16, front.readableBytes))!
286
0
287
0
            // This will only be non-zero on the last line of this loop
288
0
            let paddingAfter = 16 - slice.readableBytes
289
0
            result += slice._hexDumpLine(lineOffset: bufferOffset, paddingAfter: paddingAfter)
290
0
            bufferOffset += slice.readableBytes
291
0
        }
292
0
293
0
        result += separator
294
0
295
0
        // Dump the back maxBytes/2 bytes.
296
0
        bufferOffset = buffer.writerIndex - maxBytes / 2
297
0
        buffer.moveReaderIndex(to: bufferOffset)
298
0
        var back = buffer.readSlice(length: buffer.readableBytes)!
299
0
300
0
        // On the first line of the back part, we might want less than 16 bytes, with padding on the left.
301
0
        // But if this is also the last line, than take whatever is left.
302
0
        let lineLength = min(16 - bufferOffset % 16, back.readableBytes)
303
0
304
0
        // Line offset is the offset of the first byte of this line in a full buffer hex dump.
305
0
        // It may not match `bufferOffset` in the first line of the `back` part.
306
0
        let lineOffset = bufferOffset - bufferOffset % 16
307
0
308
0
        // Safe to force-unwrap because `back` is guaranteed to have at least one byte.
309
0
        let slice = back.readSlice(length: lineLength)!
310
0
311
0
        // paddingBefore is going to be applied both in the center column and the right column of the line.
312
0
        result += slice._hexDumpLine(lineOffset: lineOffset, paddingBefore: 16 - lineLength)
313
0
        bufferOffset += lineLength
314
0
315
0
        // Now dump the rest of the back part of the buffer.
316
0
        while back.readableBytes > 0 {
317
0
            let slice = back.readSlice(length: min(16, back.readableBytes))!
318
0
            result += slice._hexDumpLine(lineOffset: bufferOffset)
319
0
            bufferOffset += slice.readableBytes
320
0
        }
321
0
322
0
        // Last line of the dump, just the index of the last byte in the buffer.
323
0
        result += String(self.readableBytes, radix: 16, padding: 8)
324
0
        return result
325
0
    }
326
327
    /// Returns a hex dump of  this `ByteBuffer` in a preferred `HexDumpFormat`.
328
    ///
329
    /// `hexDump` provides several formats:
330
    ///   - `.plain` — plain hex dump format with hex bytes separated by spaces, i.e. `48 65 6c 6c 6f` for `Hello`. This format is compatible with `xxd -r`.
331
    ///   - `.plain(maxBytes: Int)` — like `.plain`, but clipped to maximum bytes dumped.
332
    ///   - `.compact` — plain hexd dump without whitespaces.
333
    ///   - `.compact(maxBytes: Int)` — like `.compact`, but  clipped to maximum bytes dumped.
334
    ///   - `.detailed` — detailed hex dump format with both hex, and ASCII representation of the bytes. This format is compatible with what `hexdump -C` outputs.
335
    ///   - `.detailed(maxBytes: Int)` — like `.detailed`, but  clipped to maximum bytes dumped.
336
    ///
337
    /// - Parameters:
338
    ///   - format: ``HexDumpFormat`` to use for the dump.
339
0
    public func hexDump(format: HexDumpFormat) -> String {
340
0
        switch format.value {
341
0
        case .plain(let maxBytes):
342
0
            if let maxBytes = maxBytes {
343
0
                return self.hexDumpPlain(maxBytes: maxBytes)
344
0
            } else {
345
0
                return self.hexDumpPlain()
346
0
            }
347
0
348
0
        case .compact(let maxBytes):
349
0
            if let maxBytes = maxBytes {
350
0
                return self.hexDumpCompact(maxBytes: maxBytes)
351
0
            } else {
352
0
                return self.hexDumpCompact()
353
0
            }
354
0
355
0
        case .detailed(let maxBytes):
356
0
            if let maxBytes = maxBytes {
357
0
                return self.hexDumpDetailed(maxBytes: maxBytes)
358
0
            } else {
359
0
                return self.hexdumpDetailed()
360
0
            }
361
0
        }
362
0
    }
363
364
    /// An error that is thrown when an invalid hex encoded string was attempted to be written to a ByteBuffer.
365
    public struct HexDecodingError: Error, Equatable {
366
        private let kind: HexDecodingErrorKind
367
368
        private enum HexDecodingErrorKind {
369
            /// The hex encoded string was not of the expected even length.
370
            case invalidHexLength
371
            /// An invalid hex character was found in the hex encoded string.
372
            case invalidCharacter
373
        }
374
375
        public static let invalidHexLength = HexDecodingError(kind: .invalidHexLength)
376
        public static let invalidCharacter = HexDecodingError(kind: .invalidCharacter)
377
    }
378
}
379
380
extension UInt8 {
381
0
    fileprivate var isASCIIWhitespace: Bool {
382
0
        [UInt8(ascii: "\n"), UInt8(ascii: "\t"), UInt8(ascii: "\r"), UInt8(ascii: " ")].contains(
383
0
            self
384
0
        )
385
0
    }
386
387
    fileprivate var asciiHexNibble: UInt8 {
388
0
        get throws {
389
0
            switch self {
390
0
            case UInt8(ascii: "0")...UInt8(ascii: "9"):
391
0
                return self - UInt8(ascii: "0")
392
0
            case UInt8(ascii: "a")...UInt8(ascii: "f"):
393
0
                return self - UInt8(ascii: "a") + 10
394
0
            case UInt8(ascii: "A")...UInt8(ascii: "F"):
395
0
                return self - UInt8(ascii: "A") + 10
396
0
            default:
397
0
                throw ByteBuffer.HexDecodingError.invalidCharacter
398
0
            }
399
0
        }
400
    }
401
}
402
403
extension Substring.UTF8View {
404
    @usableFromInline
405
0
    mutating func popNextHexByte() throws -> UInt8? {
406
0
        while let nextByte = self.first, nextByte.isASCIIWhitespace {
407
0
            self = self.dropFirst()
408
0
        }
409
0
410
0
        guard let firstHex = try self.popFirst()?.asciiHexNibble else {
411
0
            return nil  // No next byte to pop
412
0
        }
413
0
414
0
        guard let secondHex = try self.popFirst()?.asciiHexNibble else {
415
0
            throw ByteBuffer.HexDecodingError.invalidHexLength
416
0
        }
417
0
418
0
        return (firstHex << 4) | secondHex
419
0
    }
420
}