Skip to content

Commit 73e15bd

Browse files
committed
Add support to encode/decode hex values too
1 parent 36a947d commit 73e15bd

File tree

4 files changed

+140
-11
lines changed

4 files changed

+140
-11
lines changed

README.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ The obfuscated strings are reversible, so you can decode them back into the orig
88
encoding multiple IDs at once.
99

1010
```
11-
reversibles = ::EncodedId::ReversibleId.new(salt: my_salt)
12-
reversibles.encode([78, 45]) # "7aq6-0zqw"
13-
reversibles.decode("7aq6-0zqw") # [78, 45]
11+
reversibles = ::EncodedId::ReversibleId.new(salt: my_salt)
12+
reversibles.encode([78, 45]) # "7aq6-0zqw"
13+
reversibles.decode("7aq6-0zqw") # [78, 45]
1414
```
1515

1616
Length of the ID, the alphabet used, and the number of characters per group can be configured.
@@ -19,22 +19,26 @@ The custom alphabet (at least 16 characters needed) and character group sizes is
1919
Easily confused characters (eg `i` and `j`, `0` and `O`, `1` and `I` etc) are mapped to counterpart characters, to help
2020
common mistakes when sharing (eg customer over phone to customer service agent).
2121

22+
Also supports UUIDs if needed
23+
24+
```
25+
::EncodedId::ReversibleId.new(salt: my_salt).encode_hex("9a566b8b-8618-42ab-8db7-a5a0276401fd")
26+
=> "rppv-tg8a-cx8q-gu9e-zq15-jxes-4gpr-06xk-wfk8-aw"
27+
```
28+
2229
## Features
2330

2431
Build with https://hashids.org
2532

2633
* Hashids are reversible, no need to persist the generated Id
2734
* supports slugged IDs (eg 'beef-tenderloins-prime--p5w9-z27j')
2835
* supports multiple IDs encoded in one `EncodedId` (eg '7aq6-0zqw' decodes to `[78, 45]`)
36+
* supports encoding of hex strings (eg UUIDs), including mutliple IDs encoded in one `EncodedId`
2937
* uses a reduced character set (Crockford alphabet) & ids split into groups of letters, ie 'human-readability'
3038
* profanity limitation
3139

3240
To use with **Rails** check out the `encoded_id-rails` gem.
3341

34-
# TODO
35-
36-
- support encoding of hex strings (eg UUIDs) (see hashids#encode_hex)
37-
3842
## Note on security of encoded IDs (hashids)
3943

4044
**Encoded IDs are not secure**. It maybe possible to reverse them via brute-force. They are meant to be used in URLs as

lib/encoded_id/reversible_id.rb

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,17 @@ module EncodedId
1010
class ReversibleId
1111
ALPHABET = "0123456789abcdefghjkmnpqrstuvwxyz"
1212

13-
def initialize(salt:, length: 8, split_at: 4, alphabet: ALPHABET)
13+
def initialize(salt:, length: 8, split_at: 4, alphabet: ALPHABET, hex_digit_encoding_group_size: 4)
1414
unique_alphabet = alphabet.chars.uniq
1515
raise InvalidAlphabetError, "Alphabet must be at least 16 characters" if unique_alphabet.size < 16
1616

1717
@human_friendly_alphabet = unique_alphabet.join
1818
@salt = salt
1919
@length = length
2020
@split_at = split_at
21+
# Number of hex digits to encode in each group, larger values will result in shorter hashes for longer inputs.
22+
# Vice versa for smaller values, ie a smaller value will result in smaller hashes for small inputs.
23+
@hex_digit_encoding_group_size = hex_digit_encoding_group_size
2124
end
2225

2326
# Encode the input values into a hash
@@ -28,16 +31,27 @@ def encode(values)
2831
encoded_id
2932
end
3033

34+
# Encode hex strings into a hash
35+
def encode_hex(hexs)
36+
encode(integer_representation(hexs))
37+
end
38+
3139
# Decode the hash to original array
3240
def decode(str)
3341
encoded_id_generator.decode(convert_to_hash(str))
3442
rescue ::Hashids::InputError => e
3543
raise EncodedIdFormatError, e.message
3644
end
3745

46+
# Decode hex strings from a hash
47+
def decode_hex(str)
48+
integers = encoded_id_generator.decode(convert_to_hash(str))
49+
integers_to_hex_strings(integers)
50+
end
51+
3852
private
3953

40-
attr_reader :salt, :length, :human_friendly_alphabet, :split_at
54+
attr_reader :salt, :length, :human_friendly_alphabet, :split_at, :hex_digit_encoding_group_size
4155

4256
def prepare_input(value)
4357
inputs = value.is_a?(Array) ? value.map(&:to_i) : [value.to_i]
@@ -67,5 +81,53 @@ def map_crockford_set(str)
6781
# only use lowercase
6882
str.tr("o", "0").tr("l", "1").tr("i", "j")
6983
end
84+
85+
# TODO: optimize this
86+
def integer_representation(hexs)
87+
inputs = hexs.is_a?(Array) ? hexs.map(&:to_s) : [hexs.to_s]
88+
inputs.map! do |hex_string|
89+
cleaned = hex_string.gsub(/[^0-9a-f]/i, "")
90+
# Convert to groups of integers. Process least significant hex digits first
91+
groups = []
92+
cleaned.chars.reverse.each_with_index do |char, i|
93+
group_id = i / hex_digit_encoding_group_size.to_i
94+
groups[group_id] ||= []
95+
groups[group_id].unshift(char)
96+
end
97+
groups.map { |c| c.join.to_i(16) }
98+
end
99+
digits_to_encode = []
100+
inputs.each_with_object(digits_to_encode) do |hex_digits, digits|
101+
digits.concat(hex_digits)
102+
digits << hex_string_separator
103+
end
104+
digits_to_encode.pop unless digits_to_encode.empty? # Remove the last marker
105+
digits_to_encode
106+
end
107+
108+
# Marker to separate hex strings, must be greater than largest value encoded
109+
def hex_string_separator
110+
@hex_string_separator ||= 2.pow(hex_digit_encoding_group_size * 4) + 1
111+
end
112+
113+
# TODO: optimize this
114+
def integers_to_hex_strings(integers)
115+
hex_strings = []
116+
hex_string = []
117+
add_leading = false
118+
# Digits are encoded in least significant digit first order, but string is most significant first, so reverse
119+
integers.reverse_each do |integer|
120+
if integer == hex_string_separator # Marker to separate hex strings, so start a new one
121+
hex_strings << hex_string.join
122+
hex_string = []
123+
add_leading = false
124+
else
125+
hex_string << (add_leading ? "%.#{hex_digit_encoding_group_size}x" % integer : integer.to_s(16))
126+
add_leading = true
127+
end
128+
end
129+
hex_strings << hex_string.join unless hex_string.empty? # Add the last hex string
130+
hex_strings.reverse # Reverse final values to get the original order (the encoding process also reverses the encoded value order)
131+
end
70132
end
71133
end

sig/encoded_id.rbs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,25 @@ module EncodedId
88
class ReversibleId
99
ALPHABET: ::String
1010

11-
def initialize: (salt: ::String, ?length: ::Integer, ?split_at: ::Integer, ?alphabet: untyped) -> void
11+
def initialize: (salt: ::String, ?length: ::Integer, ?split_at: ::Integer, ?alphabet: ::String, ?hex_digit_encoding_group_size: ::Integer) -> void
1212

1313
# Encode the input values into a hash
1414
def encode: (untyped values) -> ::String
1515

16+
# Encode hex strings into a hash
17+
def encode_hex: (untyped hexs) -> ::String
18+
1619
# Decode the hash to original array
17-
def decode: (::String str) -> Array[::Integer]
20+
def decode: (::String str) -> ::Array[::Integer]
21+
22+
# Decode hex strings from a hash
23+
def decode_hex: (::String str) -> ::Array[::String]
1824

1925
private
2026

2127
@encoded_id_generator: ::Hashids
2228
@split_regex: ::Regexp
29+
@hex_string_separator: ::Integer
2330

2431
attr_reader salt: ::String
2532

@@ -29,6 +36,8 @@ module EncodedId
2936

3037
attr_reader split_at: ::Integer | nil
3138

39+
attr_reader hex_digit_encoding_group_size: ::Integer
40+
3241
def prepare_input: (untyped value) -> ::Array[::Integer]
3342

3443
def encoded_id_generator: () -> ::Hashids
@@ -40,5 +49,11 @@ module EncodedId
4049
def convert_to_hash: (::String str) -> ::String
4150

4251
def map_crockford_set: (::String str) -> ::String
52+
53+
def integer_representation: (untyped hexs) -> ::Array[::Integer]
54+
55+
def integers_to_hex_strings: (::Array[::Integer] integers) -> ::Array[::String]
56+
57+
def hex_string_separator: () -> ::Integer
4358
end
4459
end

test/test_encoded_id.rb

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,54 @@ def test_it_raises_when_hash_format_is_broken
159159
end
160160
end
161161

162+
def test_it_encodes_hexadecimal
163+
id = "f1"
164+
coded = ::EncodedId::ReversibleId.new(salt: salt).encode_hex(id)
165+
assert_equal "zryg-pey4", coded
166+
end
167+
168+
def test_it_decodes_hexadecimal
169+
coded = "zryg-pey4"
170+
id = ::EncodedId::ReversibleId.new(salt: salt).decode_hex(coded)
171+
assert_equal ["f1"], id
172+
end
173+
174+
def test_it_encodes_multiple_hexadecimal
175+
id = ["f1", "c2", "1a"]
176+
coded = ::EncodedId::ReversibleId.new(salt: salt).encode_hex(id)
177+
assert_equal "a3kf-xjk9-u9zh-5bdq-hbd", coded
178+
end
179+
180+
def test_it_decodes_multiple_hexadecimal
181+
coded = "a3kf-xjk9-u9zh-5bdq-hbd"
182+
id = ::EncodedId::ReversibleId.new(salt: salt).decode_hex(coded)
183+
assert_equal ["f1", "c2", "1a"], id
184+
end
185+
186+
def test_it_encodes_multiple_hexadecimal_with_different_length
187+
id = ["1", "c0", "97349ffe152d0013", "f0000"]
188+
coded = ::EncodedId::ReversibleId.new(salt: salt).encode_hex(id)
189+
assert_equal "rmhv-gr91-vatq-2knh-mcj3-dfmp-n6sn-epms-aed5-hkt2", coded
190+
end
191+
192+
def test_it_decodes_multiple_hexadecimal_with_different_length
193+
coded = "rmhv-gr91-vatq-2knh-mcj3-dfmp-n6sn-epms-aed5-hkt2"
194+
id = ::EncodedId::ReversibleId.new(salt: salt).decode_hex(coded)
195+
assert_equal ["1", "c0", "97349ffe152d0013", "f0000"], id
196+
end
197+
198+
def test_it_encodes_multiple_hexadecimal_as_uuids
199+
id = ["9a566b8b-8618-42ab-8db7-a5a0276401fd", "59f3905a-e704-4714-b42e-960c82b699fe", "9c0498f3-639d-41ed-87c3-715c61e14798"]
200+
coded = ::EncodedId::ReversibleId.new(salt: salt, split_at: 16).encode_hex(id)
201+
assert_equal "mxxbfa8xtqxmvt3k-4dfbz3jhg9ebuem6-jtmx6r06e3qczk56-srrrxsn5v41qb5ah-zqx2sj2aau2e3jsx-59gcd96nh8mqksdm-9jcbz8b0dkeeuxpv-bh3x6pfq5en03pbx", coded
202+
end
203+
204+
def test_it_decodes_multiple_hexadecimal_as_uuids
205+
coded = "mxxbfa8xtqxmvt3k-4dfbz3jhg9ebuem6-jtmx6r06e3qczk56-srrrxsn5v41qb5ah-zqx2sj2aau2e3jsx-59gcd96nh8mqksdm-9jcbz8b0dkeeuxpv-bh3x6pfq5en03pbx"
206+
id = ::EncodedId::ReversibleId.new(salt: salt).decode_hex(coded)
207+
assert_equal ["9a566b8b861842ab8db7a5a0276401fd", "59f3905ae7044714b42e960c82b699fe", "9c0498f3639d41ed87c3715c61e14798"], id
208+
end
209+
162210
private
163211

164212
def salt

0 commit comments

Comments
 (0)