Skip to content

Commit 8a8a3e6

Browse files
committed
Add stringified int parsing when :ex_format is integer
1 parent 285f1be commit 8a8a3e6

File tree

2 files changed

+124
-6
lines changed

2 files changed

+124
-6
lines changed

lib/once.ex

+40-5
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,24 @@ defmodule Once do
1313
- `:nonce_type` how the nonce is generated, one of `t:nonce_type/0` (default `:counter`)
1414
- `:get_key` a zero-arity getter for the 192-bits encryption key, required if encryption is enabled
1515
- `:encrypt?` **deprecated**, use `type: :encrypted` (default `false`).
16+
17+
## Integer format caveats
18+
19+
> #### Don't use raw integers with JS clients {: .warning}
20+
>
21+
> Stringify int-format Once's.
22+
23+
While JSON does not impose a precision limit on numbers, JavaScript can't deal with >= 2^53 numbers. That means the first 11 nonce bits can't be used, so the first 11 timestamp bits can't be used, which leaves 33 timestamp bits, which will run out after exactly 24 days, so let's say immediately. If you want to use integers, convert them to strings.
24+
25+
> #### `ex_format: :signed` or `:unsigned` disables encoded binary parsing {: .info}
26+
>
27+
> If you use an integer format as `:ex_format`, casting and dumping hex-encoded, url64-encoded and raw formats will be disabled.
28+
29+
That's because we can't disambiguate some binaries that are valid hex, url64 and raw binaries and also valid stringified integers. An example is "12345678901", which is either int 12_345_678_901 or url64-encoded `<<215, 109, 248, 231, 174, 252, 247, 77>>` (a.k.a. quite a different number).
30+
31+
By treating all incoming binaries as either a valid stringified int or invalid when using an integer Elixir format, this ambiguity is resolved at the cost of some flexibility. Note that `to_format/2` does *not* support stringified integers, but that does mean it converts reliably between formats once values have been cast/dumped/loaded.
32+
33+
Conversely, when using one of the binary formats, no binaries will be parsed as stringified ints.
1634
"""
1735

1836
@moduledoc """
@@ -66,7 +84,7 @@ defmodule Once do
6684
6785
The negative integers will not cause problems with Postgres and MySQL, they both happily swallow them. Also, negative integers will only start to appear after ~70 years of usage.
6886
69-
If you don't like the formats, it's really easy to change them! The Elixir format especially, which can be changed at any time. Be mindful of JSON limitations if you use integers.
87+
If you don't like the formats, it's really easy to change them! The Elixir format especially, which can be changed at any time.
7088
7189
The supported formats are:
7290
@@ -142,6 +160,11 @@ defmodule Once do
142160

143161
@impl true
144162
def cast(nil, _), do: {:ok, nil}
163+
164+
def cast(value, %{ex_format: ex_format}) when ex_format in @int_formats and is_binary(value) do
165+
parse_int_and(value, &convert_int(&1, ex_format))
166+
end
167+
145168
def cast(value, params), do: to_format(value, params.ex_format)
146169

147170
@impl true
@@ -150,6 +173,11 @@ defmodule Once do
150173

151174
@impl true
152175
def dump(nil, _, _), do: {:ok, nil}
176+
177+
def dump(value, _, params) when params.ex_format in @int_formats and is_binary(value) do
178+
parse_int_and(value, &maybe_convert(:int, &1, params.db_format))
179+
end
180+
153181
def dump(value, _, params), do: to_format(value, params.db_format)
154182

155183
@impl true
@@ -244,11 +272,11 @@ defmodule Once do
244272
defp convert_int(int, _), do: {:ok, int}
245273

246274
# paddingless url64 en/decoding
247-
defp encode64(value), do: {:ok, Base.url_encode64(value, padding: false)}
275+
defp encode64(value), do: Base.url_encode64(value, padding: false)
248276
defp decode64(value), do: Base.url_decode64(value, padding: false)
249277

250278
# hex en/decoding
251-
defp encode16(value), do: {:ok, Base.encode16(value)}
279+
defp encode16(value), do: Base.encode16(value)
252280
defp decode16(value), do: Base.decode16(value, case: :mixed)
253281

254282
# identify the value's format, where ints are grouped together for convert_int/2 to deal with
@@ -302,8 +330,8 @@ defmodule Once do
302330
# convert a raw value to another format
303331
defp from_raw(raw, to_format)
304332
defp from_raw({:ok, raw}, :raw), do: {:ok, raw}
305-
defp from_raw({:ok, raw}, :url64), do: encode64(raw)
306-
defp from_raw({:ok, raw}, :hex), do: encode16(raw)
333+
defp from_raw({:ok, raw}, :url64), do: {:ok, encode64(raw)}
334+
defp from_raw({:ok, raw}, :hex), do: {:ok, encode16(raw)}
307335
defp from_raw({:ok, <<int::signed-64>>}, :signed), do: {:ok, int}
308336
defp from_raw({:ok, <<int::unsigned-64>>}, :unsigned), do: {:ok, int}
309337
defp from_raw(_, _), do: :error
@@ -319,4 +347,11 @@ defmodule Once do
319347
end
320348

321349
defp check_nonce_type_option(params), do: params
350+
351+
defp parse_int_and(value, fun) do
352+
case Integer.parse(value) do
353+
{value, _} -> fun.(value)
354+
_ -> :error
355+
end
356+
end
322357
end

test/once_unit_test.exs

+84-1
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,8 @@ defmodule OnceUnitTest do
101101
Once.init(encrypt?: true, get_key: fn -> :crypto.strong_rand_bytes(24) end)
102102
end)
103103

104-
assert log =~ "[warning] option `:encrypt?` is deprecated, use `nonce_type: :encrypted` instead"
104+
assert log =~
105+
"[warning] option `:encrypt?` is deprecated, use `nonce_type: :encrypted` instead"
105106
end
106107

107108
test "requires get_key if :type == :encrypted" do
@@ -181,4 +182,86 @@ defmodule OnceUnitTest do
181182
assert prefix1 < prefix2
182183
end
183184
end
185+
186+
describe "cast/2" do
187+
test "accepts and decodes url64-encoded when ex_format != int" do
188+
ambiguous = "12345678901"
189+
assert {:ok, raw} = Once.cast(ambiguous, %{ex_format: :raw})
190+
assert <<int::64>> = raw
191+
assert to_string(int) != ambiguous
192+
end
193+
194+
test "accepts and decodes hex-encoded when ex_format != int" do
195+
ambiguous = "1234567890123456"
196+
assert {:ok, raw} = Once.cast(ambiguous, %{ex_format: :raw})
197+
assert <<int::64>> = raw
198+
assert to_string(int) != ambiguous
199+
end
200+
201+
test "accepts and decodes raw when ex_format != int" do
202+
ambiguous = "12345678"
203+
assert {:ok, raw} = Once.cast(ambiguous, %{ex_format: :raw})
204+
assert <<int::64>> = raw
205+
assert to_string(int) != ambiguous
206+
end
207+
208+
test "accepts and decodes url64-encoded when ex_format == int" do
209+
ambiguous = 12_345_678_901
210+
assert {:ok, ambiguous} == Once.cast("#{ambiguous}", %{ex_format: :unsigned})
211+
end
212+
213+
test "accepts and decodes hex-encoded when ex_format == int" do
214+
ambiguous = 1_234_567_890_123_456
215+
assert {:ok, ambiguous} == Once.cast("#{ambiguous}", %{ex_format: :signed})
216+
end
217+
218+
test "accepts and decodes raw when ex_format == int" do
219+
ambiguous = 12_345_678
220+
assert {:ok, ambiguous} == Once.cast("#{ambiguous}", %{ex_format: :unsigned})
221+
end
222+
end
223+
224+
describe "dump/3" do
225+
test "accepts and decodes url64-encoded when ex_format != int" do
226+
ambiguous = "12345678901"
227+
assert {:ok, raw} = Once.dump(ambiguous, nil, %{ex_format: :hex, db_format: :raw})
228+
assert <<int::64>> = raw
229+
assert to_string(int) != ambiguous
230+
end
231+
232+
test "accepts and decodes hex-encoded when ex_format != int" do
233+
ambiguous = "1234567890123456"
234+
assert {:ok, raw} = Once.dump(ambiguous, nil, %{ex_format: :hex, db_format: :raw})
235+
assert <<int::64>> = raw
236+
assert to_string(int) != ambiguous
237+
end
238+
239+
test "accepts and decodes raw when ex_format != int" do
240+
ambiguous = "12345678"
241+
assert {:ok, raw} = Once.dump(ambiguous, nil, %{ex_format: :hex, db_format: :raw})
242+
assert <<int::64>> = raw
243+
assert to_string(int) != ambiguous
244+
end
245+
246+
test "accepts and decodes url64-encoded when ex_format == int" do
247+
ambiguous = 12_345_678_901
248+
249+
assert {:ok, ambiguous} ==
250+
Once.dump("#{ambiguous}", nil, %{ex_format: :unsigned, db_format: :signed})
251+
end
252+
253+
test "accepts and decodes hex-encoded when ex_format == int" do
254+
ambiguous = 1_234_567_890_123_456
255+
256+
assert {:ok, ambiguous} ==
257+
Once.dump("#{ambiguous}", nil, %{ex_format: :signed, db_format: :signed})
258+
end
259+
260+
test "accepts and decodes raw when ex_format == int" do
261+
ambiguous = 12_345_678
262+
263+
assert {:ok, ambiguous} ==
264+
Once.dump("#{ambiguous}", nil, %{ex_format: :unsigned, db_format: :signed})
265+
end
266+
end
184267
end

0 commit comments

Comments
 (0)