Module: Philiprehberger::Hex

Defined in:
lib/philiprehberger/hex.rb,
lib/philiprehberger/hex/version.rb

Defined Under Namespace

Classes: Error

Constant Summary collapse

HEX_PATTERN =
/\A[0-9a-fA-F]*\z/
VERSION =
'0.5.0'

Class Method Summary collapse

Class Method Details

.and(hex1, hex2) ⇒ String

Bitwise AND of two equal-length hex strings

Parameters:

  • hex1 (String)
  • hex2 (String)

Returns:

  • (String)

    lowercase hex result



315
316
317
# File 'lib/philiprehberger/hex.rb', line 315

def self.and(hex1, hex2)
  bitwise_binop(hex1, hex2) { |a, b| a & b }
end

.byte_length(hex) ⇒ Integer

Return the decoded byte count of a hex string without decoding Strips 0x/0X prefix and whitespace/separators before counting

Parameters:

  • hex (String)

Returns:

  • (Integer)

    number of bytes the hex would decode to

Raises:



268
269
270
271
272
273
274
275
# File 'lib/philiprehberger/hex.rb', line 268

def self.byte_length(hex)
  validate_string!(hex)
  cleaned = strip_prefix(hex).gsub(/[\s:\-_]/, '')
  raise Error, 'invalid hex string: odd length' if cleaned.length.odd?
  raise Error, 'invalid hex string: non-hex characters' unless cleaned.empty? || valid?(cleaned)

  cleaned.length / 2
end

.bytes_from(hex) ⇒ Array<Integer>

Convert a hex string to an array of integer byte values

Parameters:

  • hex (String)

    hex-encoded string (even length)

Returns:

  • (Array<Integer>)

    array of byte values

Raises:



91
92
93
94
95
96
97
# File 'lib/philiprehberger/hex.rb', line 91

def self.bytes_from(hex)
  validate_string!(hex)
  raise Error, 'invalid hex string: odd length' if hex.length.odd?
  raise Error, 'invalid hex string: non-hex characters' unless valid?(hex)

  [hex].pack('H*').bytes
end

.chunk(hex, size:) ⇒ Array<String>

Split a hex string into an array of byte-aligned chunks The last chunk may be shorter than size bytes if the input length is not a multiple

Parameters:

  • hex (String)
  • size (Integer)

    bytes per chunk (>= 1)

Returns:

  • (Array<String>)

Raises:



299
300
301
302
303
304
305
306
307
308
# File 'lib/philiprehberger/hex.rb', line 299

def self.chunk(hex, size:)
  validate_string!(hex)
  raise Error, 'size must be a positive integer' unless size.is_a?(Integer) && size.positive?

  stripped = strip_prefix(hex)
  raise Error, 'invalid hex string: odd length' if stripped.length.odd?
  raise Error, 'invalid hex string: non-hex characters' unless stripped.empty? || valid?(stripped)

  stripped.scan(/.{1,#{size * 2}}/)
end

.compare(hex1, hex2) ⇒ Array<Hash>

Compare two hex strings and return byte-level differences

Parameters:

  • hex1 (String)

    first hex string

  • hex2 (String)

    second hex string

Returns:

  • (Array<Hash>)

    array of { offset:, expected:, actual: } for differing bytes



104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/philiprehberger/hex.rb', line 104

def self.compare(hex1, hex2)
  bytes1 = bytes_from(hex1)
  bytes2 = bytes_from(hex2)

  max_len = [bytes1.length, bytes2.length].max
  diffs = []

  max_len.times do |i|
    b1 = bytes1[i]
    b2 = bytes2[i]
    next if b1 == b2

    diffs << {
      offset: i,
      expected: b1 ? Kernel.format('%02x', b1) : nil,
      actual: b2 ? Kernel.format('%02x', b2) : nil
    }
  end

  diffs
end

.decode(hex) ⇒ String

Decode a hexadecimal string to binary Automatically strips 0x/0X prefix if present

Parameters:

  • hex (String)

    hex-encoded string

Returns:

  • (String)

    decoded binary string

Raises:



37
38
39
40
41
42
43
44
# File 'lib/philiprehberger/hex.rb', line 37

def self.decode(hex)
  validate_string!(hex)
  hex = strip_prefix(hex)
  raise Error, 'invalid hex string: odd length' if hex.length.odd?
  raise Error, 'invalid hex string: non-hex characters' unless valid?(hex)

  [hex].pack('H*')
end

.dump(str) ⇒ String

Produce an xxd-style hex dump

Parameters:

  • str (String)

Returns:

  • (String)

    formatted hex dump



50
51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/philiprehberger/hex.rb', line 50

def self.dump(str)
  validate_string!(str)
  lines = []
  bytes = str.bytes

  bytes.each_slice(16).with_index do |chunk, index|
    offset = Kernel.format('%08x', index * 16)
    hex_part = chunk.each_slice(2).map { |pair| pair.map { |b| Kernel.format('%02x', b) }.join }.join(' ')
    ascii_part = chunk.map { |b| b.between?(32, 126) ? b.chr : '.' }.join
    lines << Kernel.format('%-10s %-40s %s', "#{offset}:", hex_part, ascii_part)
  end

  lines.join("\n")
end

.encode(str, prefix: false, uppercase: false) ⇒ String

Encode a string to hexadecimal

Parameters:

  • str (String)
  • prefix (Boolean) (defaults to: false)

    prepend “0x” prefix

  • uppercase (Boolean) (defaults to: false)

    use uppercase hex characters

Returns:

  • (String)

    hex-encoded string



25
26
27
28
29
30
# File 'lib/philiprehberger/hex.rb', line 25

def self.encode(str, prefix: false, uppercase: false)
  validate_string!(str)
  hex = str.unpack1('H*')
  hex = hex.upcase if uppercase
  prefix ? "0x#{hex}" : hex
end

.extract_range(hex, offset:, length:) ⇒ String

Extract a range of bytes from a hex string

Parameters:

  • hex (String)

    hex-encoded string

  • offset (Integer)

    byte offset to start from

  • length (Integer)

    number of bytes to extract

Returns:

  • (String)

    hex substring

Raises:



155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/philiprehberger/hex.rb', line 155

def self.extract_range(hex, offset:, length:)
  validate_string!(hex)
  hex = strip_prefix(hex)
  raise Error, 'invalid hex string: odd length' if hex.length.odd?
  raise Error, 'invalid hex string: non-hex characters' unless valid?(hex)

  total_bytes = hex.length / 2
  raise Error, 'offset out of range' if offset.negative? || offset >= total_bytes
  raise Error, 'length out of range' if length.negative? || (offset + length) > total_bytes

  hex[offset * 2, length * 2]
end

.format(str, group: 2) ⇒ String

Format a string as grouped hex

Parameters:

  • str (String)
  • group (Integer) (defaults to: 2)

    number of bytes per group

Returns:

  • (String)

    grouped hex string



70
71
72
73
74
# File 'lib/philiprehberger/hex.rb', line 70

def self.format(str, group: 2)
  validate_string!(str)
  hex = encode(str)
  hex.scan(/.{1,#{group * 2}}/).join(' ')
end

.from_bytes(bytes) ⇒ String

Convert an array of byte integers (0..255) to a hex string

Parameters:

  • bytes (Array<Integer>)

Returns:

  • (String)

    lowercase hex

Raises:



238
239
240
241
242
243
244
245
# File 'lib/philiprehberger/hex.rb', line 238

def self.from_bytes(bytes)
  raise Error, 'expected an Array' unless bytes.is_a?(Array)
  unless bytes.all? { |b| b.is_a?(Integer) && b.between?(0, 255) }
    raise Error, 'all elements must be integers in 0..255'
  end

  bytes.pack('C*').unpack1('H*')
end

.from_int(int, bytes: nil) ⇒ String

Convert an integer to a hex string

Parameters:

  • int (Integer)
  • bytes (Integer, nil) (defaults to: nil)

    optional zero-padded byte count

Returns:

  • (String)

    hex-encoded string

Raises:



219
220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'lib/philiprehberger/hex.rb', line 219

def self.from_int(int, bytes: nil)
  raise Error, 'expected an Integer' unless int.is_a?(Integer)
  raise Error, 'integer must be non-negative' if int.negative?

  hex = int.to_s(16)
  hex = "0#{hex}" if hex.length.odd?

  if bytes
    target = bytes * 2
    hex = hex.rjust(target, '0') if hex.length < target
  end

  hex
end

.normalize(hex, uppercase: false) ⇒ String

Normalize a hex string by stripping prefix, whitespace, and separators Validates that the result is even-length and purely hexadecimal

Parameters:

  • hex (String)
  • uppercase (Boolean) (defaults to: false)

    return uppercase instead of lowercase

Returns:

  • (String)

    canonical hex

Raises:



253
254
255
256
257
258
259
260
261
# File 'lib/philiprehberger/hex.rb', line 253

def self.normalize(hex, uppercase: false)
  validate_string!(hex)
  stripped = strip_prefix(hex).gsub(/[\s:\-_]/, '')
  raise Error, 'invalid hex string: odd length' if stripped.length.odd?
  raise Error, 'invalid hex string: empty' if stripped.empty?
  raise Error, 'invalid hex string: non-hex characters' unless valid?(stripped)

  uppercase ? stripped.upcase : stripped.downcase
end

.not(hex) ⇒ String

Bitwise NOT (one’s complement) of a hex string

Parameters:

  • hex (String)

Returns:

  • (String)

    lowercase hex result



332
333
334
# File 'lib/philiprehberger/hex.rb', line 332

def self.not(hex)
  bytes_from(hex).map { |b| Kernel.format('%02x', ~b & 0xFF) }.join
end

.or(hex1, hex2) ⇒ String

Bitwise OR of two equal-length hex strings

Parameters:

  • hex1 (String)
  • hex2 (String)

Returns:

  • (String)

    lowercase hex result



324
325
326
# File 'lib/philiprehberger/hex.rb', line 324

def self.or(hex1, hex2)
  bitwise_binop(hex1, hex2) { |a, b| a | b }
end

.pad(hex, length:, side: :left) ⇒ String

Pad a hex string to a target byte length with zeros

Parameters:

  • hex (String)

    hex-encoded string

  • length (Integer)

    target byte length

  • side (Symbol) (defaults to: :left)

    :left or :right

Returns:

  • (String)

    padded hex string

Raises:



187
188
189
190
191
192
193
194
195
196
197
198
199
# File 'lib/philiprehberger/hex.rb', line 187

def self.pad(hex, length:, side: :left)
  validate_string!(hex)
  hex = strip_prefix(hex)
  raise Error, 'invalid hex string: odd length' if hex.length.odd?
  raise Error, 'invalid hex string: non-hex characters' unless valid?(hex)
  raise Error, 'side must be :left or :right' unless %i[left right].include?(side)

  target_chars = length * 2
  return hex if hex.length >= target_chars

  padding = '0' * (target_chars - hex.length)
  side == :left ? "#{padding}#{hex}" : "#{hex}#{padding}"
end

.random(n) ⇒ String

Generate a random hex string of n bytes

Parameters:

  • n (Integer)

    number of random bytes

Returns:

  • (String)

    hex-encoded random string (2*n characters)

Raises:



143
144
145
146
147
# File 'lib/philiprehberger/hex.rb', line 143

def self.random(n)
  raise Error, 'byte count must be positive' unless n.is_a?(Integer) && n.positive?

  SecureRandom.hex(n)
end

.secure_equal?(hex1, hex2) ⇒ Boolean

Constant-time hex comparison, safe for MAC/HMAC/signature checks Comparison is case-insensitive; lengths must match (length is not secret)

Parameters:

  • hex1 (String)
  • hex2 (String)

Returns:

  • (Boolean)


283
284
285
286
287
288
289
290
291
# File 'lib/philiprehberger/hex.rb', line 283

def self.secure_equal?(hex1, hex2)
  validate_string!(hex1)
  validate_string!(hex2)
  a = strip_prefix(hex1).downcase
  b = strip_prefix(hex2).downcase
  return false unless a.length == b.length

  OpenSSL.fixed_length_secure_compare(a, b)
end

.swap_endian(hex) ⇒ String

Reverse byte order of a hex string

Parameters:

  • hex (String)

    hex-encoded string (even length)

Returns:

  • (String)

    hex string with reversed byte order

Raises:



172
173
174
175
176
177
178
179
# File 'lib/philiprehberger/hex.rb', line 172

def self.swap_endian(hex)
  validate_string!(hex)
  hex = strip_prefix(hex)
  raise Error, 'invalid hex string: odd length' if hex.length.odd?
  raise Error, 'invalid hex string: non-hex characters' unless valid?(hex)

  hex.scan(/../).reverse.join
end

.to_int(hex) ⇒ Integer

Convert a hex string to an integer

Parameters:

  • hex (String)

    hex-encoded string

Returns:

  • (Integer)

Raises:



205
206
207
208
209
210
211
212
# File 'lib/philiprehberger/hex.rb', line 205

def self.to_int(hex)
  validate_string!(hex)
  hex = strip_prefix(hex)
  raise Error, 'invalid hex string: empty' if hex.empty?
  raise Error, 'invalid hex string: non-hex characters' unless valid?(hex)

  hex.to_i(16)
end

.valid?(str) ⇒ Boolean

Check if a string is valid hexadecimal

Parameters:

  • str (String)

Returns:

  • (Boolean)


80
81
82
83
84
85
# File 'lib/philiprehberger/hex.rb', line 80

def self.valid?(str)
  return false unless str.is_a?(String)
  return false if str.empty?

  HEX_PATTERN.match?(str)
end

.xor(hex1, hex2) ⇒ String

XOR two hex strings and return the hex result

Parameters:

  • hex1 (String)

    first hex string

  • hex2 (String)

    second hex string

Returns:

  • (String)

    hex-encoded XOR result

Raises:



131
132
133
134
135
136
137
# File 'lib/philiprehberger/hex.rb', line 131

def self.xor(hex1, hex2)
  bytes1 = bytes_from(hex1)
  bytes2 = bytes_from(hex2)
  raise Error, 'hex strings must be the same length' unless bytes1.length == bytes2.length

  bytes1.zip(bytes2).map { |a, b| Kernel.format('%02x', a ^ b) }.join
end