Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Adding ByteBuffer.hexDump in xxd and hexdump -C formats #2475

Merged
merged 30 commits into from
Aug 15, 2023
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
3de39bb
Initial implementation of hexdump for ByteBuffer
natikgadzhi Jul 14, 2023
fb8d173
Put that iff back there
natikgadzhi Jul 15, 2023
066d7e0
Reword hexDumpShort(): naming, tests, offset and full length support
natikgadzhi Jul 15, 2023
e89714b
Use writerIndex to figure out the convinience end of useful content i…
natikgadzhi Jul 15, 2023
5e5dede
hexDumpLong(offset:) — supports offset, and reads full buffer slice, …
natikgadzhi Jul 15, 2023
64e3089
hexDumpLong() extra line failing test
natikgadzhi Jul 15, 2023
4134644
Using ByteBuffer(string:) instead of the allocator
natikgadzhi Aug 1, 2023
b640602
Move hexDumps code into ByteBuffer-hexdump.swift
natikgadzhi Aug 1, 2023
006bf1d
Optimized String(byte: poadding:)
natikgadzhi Aug 1, 2023
3e0573f
Avoid using storage.dumpBytes in hexDumpShort()
natikgadzhi Aug 1, 2023
a31717e
Implemented a cleaner long hexdump
natikgadzhi Aug 1, 2023
f188f67
Implemented the public hexdump API
natikgadzhi Aug 1, 2023
6716354
Revert ByteBuffer-core.swift to main
natikgadzhi Aug 2, 2023
5edc450
Addressed review feedback!
natikgadzhi Aug 2, 2023
2f0d956
hexdump long format with clipped length
natikgadzhi Aug 2, 2023
c710f94
Merge branch 'main' into feature/byte-buffer-hexdump
natikgadzhi Aug 2, 2023
fbd8c26
reserve capacity in hexDumpLines
natikgadzhi Aug 2, 2023
849e577
Addressing review feedback
natikgadzhi Aug 7, 2023
bea0e15
Merge branch 'main' into feature/byte-buffer-hexdump
weissi Aug 7, 2023
74857cc
String(_ value, radix, padding) and the final test case
natikgadzhi Aug 7, 2023
716a455
Make the hexDump(format: .detailed) format cleaner
natikgadzhi Aug 9, 2023
b62ca3e
Spell out if let for Swift 5.8
natikgadzhi Aug 9, 2023
5b2d97c
Fixed a formatting bug in multiline maxBytes detailed format
natikgadzhi Aug 9, 2023
75a82b4
Apply suggestions from code review
natikgadzhi Aug 13, 2023
d057109
Cleaner way to dump a line in .detailed format
natikgadzhi Aug 13, 2023
56abf58
Merge branch 'main' into feature/byte-buffer-hexdump
natikgadzhi Aug 13, 2023
96c077b
Cleaner generic argument type for String(_, radix:, padding:)
natikgadzhi Aug 14, 2023
47bebae
Revert _storage.dumpBytes test to match previous format
natikgadzhi Aug 14, 2023
dc65676
Simplify printable bytes closure to a single expression so Swift 5.6 …
natikgadzhi Aug 15, 2023
c26275b
Merge branch 'main' into feature/byte-buffer-hexdump
Lukasa Aug 15, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion Sources/NIOCore/ByteBuffer-conversions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,17 @@ extension String {
var buffer = buffer
self = buffer.readString(length: buffer.readableBytes)!
}


/// Creates a `String` from a given `Int` with a given base (`radix`), padded with zeroes to the provided `padding` size.
///
/// - parameters:
/// - radix: radix base to use for conversion.
/// - padding: the desired lenght of the resulting string.
@inlinable
internal init<T>(_ value: T, radix: Int, padding: Int) where T: BinaryInteger {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we give this a better generic type parameter name? Maybe Value?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call. Done in 96c077b.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nicely done

let formatted = String(value, radix: radix)
self = String(repeating: "0", count: padding - formatted.count) + formatted
}
}

extension DispatchData {
Expand Down
266 changes: 266 additions & 0 deletions Sources/NIOCore/ByteBuffer-hexdump.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the SwiftNIO open source project
//
// Copyright (c) 2023 Apple Inc. and the SwiftNIO project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of SwiftNIO project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

extension ByteBuffer {

/// Describes a ByteBuffer hexDump format.
/// Can be either xxd output compatible, or hexdump compatible.
public struct HexDumpFormat: Hashable, Sendable {

enum Value: Hashable {
case plain(maxBytes: Int? = nil)
case detailed(maxBytes: Int? = nil)
}

let value: Value
init(_ value: Value) { self.value = value }

/// A plain hex dump format compatible with `xxd` CLI utility.
public static let plain = Self(.plain(maxBytes: nil))

/// A hex dump format compatible with `hexdump` command line utility.
public static let detailed = Self(.detailed(maxBytes: nil))

/// A detailed hex dump format compatible with `xxd`, clipped to `maxBytes` bytes dumped.
/// This format will dump first `maxBytes / 2` bytes, and the last `maxBytes / 2` bytes, replacing the rest with " ... ".
public static func plain(maxBytes: Int) -> Self {
Self(.plain(maxBytes: maxBytes))
}

/// A hex dump format compatible with `hexdump` command line tool.
/// This format will dump first `maxBytes / 2` bytes, and the last `maxBytes / 2` bytes, with a placeholder in between.
public static func detailed(maxBytes: Int) -> Self {
Self(.detailed(maxBytes: maxBytes))
}
}

/// Return a `String` of space separated hexadecimal digits of the readable bytes in the buffer,
/// in a format that's compatible with `xxd -r -p`.
/// `hexDumpPlain()` always dumps all readable bytes, i.e. from `readerIndex` to `writerIndex`,
/// so you should set those indices to desired location to get the offset and length that you need to dump.
private func hexDumpPlain() -> String {
var hexString = ""
hexString.reserveCapacity(self.readableBytes * 3)

for byte in self.readableBytesView {
hexString += String(byte, radix: 16, padding: 2)
hexString += " "
}

return String(hexString.dropLast())
}

/// Return a `String` of space delimited hexadecimal digits of the readable bytes in the buffer,
/// in a format that's compatible with `xxd -r -p`, but clips the output to the max length of `maxBytes` bytes.
/// If the dump contains more than the `maxBytes` bytes, this function will return the first `maxBytes/2`
/// and the last `maxBytes/2` of that, replacing the rest with `...`, i.e. `01 02 03 ... 09 11 12`.
///
/// - parameters:
/// - maxBytes: The maximum amount of bytes presented in the dump.
private func hexDumpPlain(maxBytes: Int) -> String {
// If the buffer length fits in the max bytes limit in the hex dump, just dump the whole thing.
if self.readableBytes <= maxBytes {
return self.hexDump(format: .plain)
}

var buffer = self

// Safe to force-unwrap because we just checked readableBytes is > maxBytes above.
let front = buffer.readSlice(length: maxBytes / 2)!
buffer.moveReaderIndex(to: buffer.writerIndex - maxBytes / 2)
let back = buffer.readSlice(length: buffer.readableBytes)!

let startHex = front.hexDumpPlain()
let endHex = back.hexDumpPlain()
return startHex + " ... " + endHex
}

/// Returns a `String` containing a detailed hex dump of this buffer.
/// Intended to be used internally in ``hexDump(format:)``
/// - parameters:
/// - 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.
/// - paddingBefore: the amount of space to pad before the first byte dumped on this line, used in center and right columns.
/// - paddingAfter: the amount of sapce to pad after the last byte on this line, used in center and right columns.
private func _hexDumpLine(lineOffset: Int, paddingBefore: Int = 0, paddingAfter: Int = 0) -> String {
// Each line takes 78 visible characters + \n
var result = ""
result.reserveCapacity(79)

// Left column of the hex dump signifies the offset from the beginning of the dumped buffer
// and is separated from the next column with two spaces.
result += String(lineOffset, radix: 16, padding: 8)
result += " "

// Center column consists of:
// - xxd-compatible dump of the first 8 bytes
// - space
// - xxd-compatible dump of the rest 8 bytes
// If there are not enough bytes to dump, the column is padded with space.

// If there's any padding on the left, apply that first.
result += String(repeating: " ", count: paddingBefore * 3)

// Add the left side of the central column
let bytesInLeftColumn = max(8 - paddingBefore, 0)
for byte in self.readableBytesView.prefix(bytesInLeftColumn) {
result += String(byte, radix: 16, padding: 2)
result += " "
}

// Add an extra space for the centre column.
result += " "

// Add the right side of the central column.
for byte in self.readableBytesView.dropFirst(bytesInLeftColumn) {
result += String(byte, radix: 16, padding: 2)
result += " "
}

// Pad the resulting center column line to 60 characters.
result += String(repeating: " ", count: 60 - result.count)

// Right column renders the 16 bytes line as ASCII characters, or "." if the character is not printable.
let printableBytes = self.readableBytesView.map {
let printableRange = UInt8(ascii: " ") ..< UInt8(ascii: "~")
return printableRange.contains($0) ? $0 : UInt8(ascii: ".")
}

result += "|"
result += String(repeating: " ", count: paddingBefore)
result += String(decoding: printableBytes, as: UTF8.self)
result += String(repeating: " ", count: paddingAfter)
result += "|\n"
return result
}

/// Returns a `String` of hexadecimal digits of bytes in the Buffer,
/// with formatting compatible with output of `hexdump -C`.
private func hexdumpDetailed() -> String {
if self.readableBytes == 0 {
return ""
}

var result = ""
result.reserveCapacity(self.readableBytes / 16 * 79 + 8)

var buffer = self

var lineOffset = 0
while buffer.readableBytes > 0 {
// Safe to force-unwrap because we're in a loop that guarantees there's at least one byte to read.
let slice = buffer.readSlice(length: min(16, buffer.readableBytes))!
result += slice._hexDumpLine(lineOffset: lineOffset)
lineOffset += slice.readableBytes
}

result += String(self.readableBytes, radix: 16, padding: 8)
return result
}

/// Returns a `String` of hexadecimal digits of bytes in this ByteBuffer
/// with formatting sort of compatible with `hexdump -C`, but clipped on length.
/// Dumps limit/2 first and limit/2 last bytes, with a separator line in between.
///
/// - parameters:
/// - maxBytes: Max bytes to dump.
private func hexDumpDetailed(maxBytes: Int) -> String {
if self.readableBytes <= maxBytes {
return self.hexdumpDetailed()
}

let separator = "........ .. .. .. .. .. .. .. .. .. .. .. .. .. .. .. .. ..................\n"

// reserve capacity for the maxBytes dumped, plus the separator line, and buffer length line.
var result = ""
result.reserveCapacity(maxBytes/16 * 79 + 79 + 8)

var buffer = self

// Dump the front part of the buffer first, up to maxBytes/2 bytes.
// Safe to force-unwrap because we know the buffer has more readable bytes than maxBytes.
var front = buffer.readSlice(length: maxBytes / 2)!
var bufferOffset = 0
while front.readableBytes > 0 {
// Safe to force-unwrap because buffer is guaranteed to have at least one byte in it.
let slice = front.readSlice(length: min(16, front.readableBytes))!

// This will only be non-zero on the last line of this loop
let paddingAfter = 16 - slice.readableBytes
result += slice._hexDumpLine(lineOffset: bufferOffset, paddingAfter: paddingAfter)
bufferOffset += slice.readableBytes
}

result += separator

// Dump the back maxBytes/2 bytes.
bufferOffset = buffer.writerIndex - maxBytes / 2
buffer.moveReaderIndex(to: bufferOffset)
var back = buffer.readSlice(length: buffer.readableBytes)!

// On the first line of the back part, we might want less than 16 bytes, with padding on the left.
// But if this is also the last line, than take whatever is left.
let lineLength = min(16 - bufferOffset % 16, back.readableBytes)

// Line offset is the offset of the first byte of this line in a full buffer hex dump.
// It may not match `bufferOffset` in the first line of the `back` part.
let lineOffset = bufferOffset - bufferOffset % 16

// Safe to force-unwrap because `back` is guaranteed to have at least one byte.
let slice = back.readSlice(length: lineLength)!

// paddingBefore is going to be applied both in the center column and the right column of the line.
result += slice._hexDumpLine(lineOffset: lineOffset, paddingBefore: 16 - lineLength)
bufferOffset += lineLength

// Now dump the rest of the back part of the buffer.
while back.readableBytes > 0 {
let slice = back.readSlice(length: min(16, back.readableBytes))!
result += slice._hexDumpLine(lineOffset: bufferOffset)
bufferOffset += slice.readableBytes
}

// Last line of the dump, just the index of the last byte in the buffer.
result += String(self.readableBytes, radix: 16, padding: 8)
return result
}

/// Returns a hex dump of this `ByteBuffer` in a preferred `HexDumpFormat`.
///
/// `hexDump` provides four formats:
/// - `.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`.
/// - `.plain(maxBytes: Int)` — like `.plain`, but clipped to maximum bytes dumped.
/// - `.detailed` — detailed hex dump format with both hex, and ASCII representation of the bytes. This format is compatible with what `hexdump -C` outputs.
/// - `.detailed(maxBytes: Int)` — like `.detailed`, but clipped to maximum bytes dumped.
///
/// - parameters:
/// - format: ``HexDumpFormat`` to use for the dump.
public func hexDump(format: HexDumpFormat) -> String {
switch(format.value) {
case .plain(let maxBytes):
if let maxBytes = maxBytes {
return self.hexDumpPlain(maxBytes: maxBytes)
} else {
return self.hexDumpPlain()
}

case .detailed(let maxBytes):
if let maxBytes = maxBytes {
return self.hexDumpDetailed(maxBytes: maxBytes)
} else {
return self.hexdumpDetailed()
}
}
}
}

93 changes: 91 additions & 2 deletions Tests/NIOCoreTests/ByteBufferTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1843,19 +1843,108 @@ class ByteBufferTest: XCTestCase {
}
let actual = self.buf._storage.dumpBytes(slice: self.buf._slice, offset: 0, length: self.buf.readableBytes)
let expected = """
[ \
00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f 10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f \
20 21 22 23 24 25 26 27 28 29 2a 2b 2c 2d 2e 2f 30 31 32 33 34 35 36 37 38 39 3a 3b 3c 3d 3e 3f \
40 41 42 43 44 45 46 47 48 49 4a 4b 4c 4d 4e 4f 50 51 52 53 54 55 56 57 58 59 5a 5b 5c 5d 5e 5f \
60 61 62 63 64 65 66 67 68 69 6a 6b 6c 6d 6e 6f 70 71 72 73 74 75 76 77 78 79 7a 7b 7c 7d 7e 7f \
80 81 82 83 84 85 86 87 88 89 8a 8b 8c 8d 8e 8f 90 91 92 93 94 95 96 97 98 99 9a 9b 9c 9d 9e 9f \
a0 a1 a2 a3 a4 a5 a6 a7 a8 a9 aa ab ac ad ae af b0 b1 b2 b3 b4 b5 b6 b7 b8 b9 ba bb bc bd be bf \
c0 c1 c2 c3 c4 c5 c6 c7 c8 c9 ca cb cc cd ce cf d0 d1 d2 d3 d4 d5 d6 d7 d8 d9 da db dc dd de df \
e0 e1 e2 e3 e4 e5 e6 e7 e8 e9 ea eb ec ed ee ef f0 f1 f2 f3 f4 f5 f6 f7 f8 f9 fa fb fc fd fe ff ]
e0 e1 e2 e3 e4 e5 e6 e7 e8 e9 ea eb ec ed ee ef f0 f1 f2 f3 f4 f5 f6 f7 f8 f9 fa fb fc fd fe ff
"""
XCTAssertEqual(expected, actual)
}

func testHexDumpPlain() {
let buf = ByteBuffer(string: "Hello")
XCTAssertEqual("48 65 6c 6c 6f", buf.hexDump(format: .plain))
}

func testHexDumpPlainEmptyBuffer() {
let buf = ByteBuffer(string: "")
XCTAssertEqual("", buf.hexDump(format: .plain))
}

func testHexDumpPlainWithReaderIndexOffset() {
var buf = ByteBuffer(string: "Hello")
let firstTwo = buf.readBytes(length: 2)!
XCTAssertEqual([72, 101], firstTwo)
XCTAssertEqual("6c 6c 6f", buf.hexDump(format: .plain))
}

func testHexDumpPlainWithMaxBytes() {
self.buf.clear()
for f in UInt8.min...UInt8.max {
self.buf.writeInteger(f)
}
let actual = self.buf.hexDump(format: .plain(maxBytes: 10))
let expected = "00 01 02 03 04 ... fb fc fd fe ff"
XCTAssertEqual(expected, actual)
}

func testHexDumpDetailed() {
let buf = ByteBuffer(string: "Goodbye, world! It was nice knowing you.\n")
let expected = """
00000000 47 6f 6f 64 62 79 65 2c 20 77 6f 72 6c 64 21 20 |Goodbye, world! |
00000010 49 74 20 77 61 73 20 6e 69 63 65 20 6b 6e 6f 77 |It was nice know|
00000020 69 6e 67 20 79 6f 75 2e 0a |ing you..|
00000029
"""
let actual = buf.hexDump(format: .detailed)
XCTAssertEqual(expected, actual)
}


func testHexDumpDetailedWithMaxBytes() {
let buf = ByteBuffer(string: "Goodbye, world! It was nice knowing you.\n")
let expected = """
00000000 47 6f 6f 64 62 79 65 2c |Goodbye, |
........ .. .. .. .. .. .. .. .. .. .. .. .. .. .. .. .. ..................
00000020 6e 67 20 79 6f 75 2e 0a | ng you..|
00000029
"""
let actual = buf.hexDump(format: .detailed(maxBytes: 16))
XCTAssertEqual(expected, actual)
}

func testHexDumpDetailedWithMultilineFrontAndBack() {
let buf = ByteBuffer(string: """
Goodbye, world! It was nice knowing you.
I will miss this pull request with all of it's 94+ comments.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can keep it going if you like? 😅 (Also, thank you again, this is a really awesome addition!)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

such good changes

""")

let expected = """
00000000 47 6f 6f 64 62 79 65 2c 20 77 6f 72 6c 64 21 20 |Goodbye, world! |
00000010 49 74 |It |
........ .. .. .. .. .. .. .. .. .. .. .. .. .. .. .. .. ..................
00000050 69 74 27 73 20 39 34 2b 20 63 6f 6d 6d | it's 94+ comm|
00000060 65 6e 74 73 2e |ents.|
00000065
"""
let actual = buf.hexDump(format: .detailed(maxBytes: 36))
XCTAssertEqual(expected, actual)
}

func testHexDumpDetailedWithOffset() {
var buf = ByteBuffer(string: "Goodbye, world! It was nice knowing you.\n")
let _ = buf.readBytes(length: 5)
let expected = """
00000000 79 65 2c 20 77 6f 72 6c 64 21 20 49 74 20 77 61 |ye, world! It wa|
00000010 73 20 6e 69 63 65 20 6b 6e 6f 77 69 6e 67 20 79 |s nice knowing y|
00000020 6f 75 2e 0a |ou..|
00000024
"""
let actual = buf.hexDump(format: .detailed)
XCTAssertEqual(expected, actual)
}

func testHexDumpLongEmptyBuffer() {
let buf = ByteBuffer()
let expected = ""
let actual = buf.hexDump(format: .detailed)
XCTAssertEqual(expected, actual)
}

func testReadableBytesView() throws {
self.buf.clear()
self.buf.writeString("hello world 012345678")
Expand Down