defmodule Utils do
@moduledoc false
def invert(m) do
{_array, result} = Enum.map_reduce(m, %{},
fn {k, v}, acc -> {nil, Map.put(acc, v, k)} end)
result
end
end
defmodule NilValuesError do
@moduledoc false
defexception message: "nil value(s)"
end
defmodule InvalidBase45Error do
@moduledoc false
defexception message: "Invalid Base45 string"
end
defmodule InputOutputError do
@moduledoc false
defexception message: "Input/output error"
end
defmodule Base45 do
@moduledoc """
A library to encode and decode binaries using the base 45 encoding scheme (specified in RFC 9285).
"""
@value_to_code %{0 => "0",
1 => "1",
2 => "2",
3 => "3",
4 => "4",
5 => "5",
6 => "6",
7 => "7",
8 => "8",
9 => "9",
10 => "A",
11 => "B",
12 => "C",
13 => "D",
14 => "E",
15 => "F",
16 => "G",
17 => "H",
18 => "I",
19 => "J",
20 => "K",
21 => "L",
22 => "M",
23 => "N",
24 => "O",
25 => "P",
26 => "Q",
27 => "R",
28 => "S",
29 => "T",
30 => "U",
31 => "V",
32 => "W",
33 => "X",
34 => "Y",
35 => "Z",
36 => " ",
37 => "$",
38 => "%",
39 => "*",
40 => "+",
41 => "-",
42 => ".",
43 => "/",
44 => ":"}
@doc false
@spec value_to_code() :: map
def value_to_code() do
@value_to_code
end
@code_to_value Utils.invert(@value_to_code)
@doc """
Encode binary data as Base45
## Examples
iex> Base45.encode("Hello")
"%69 VDL2"
iex(1)> Base45.encode(<<0, 1, 127, 129, 255>>)
"100G5GU5"
"""
@spec encode(binary) :: String.t
# Erlang has a limit for its function byte_size, you get an
# ArgumentError if you exceed it. pow(2, 24)?
def encode(data) do
couples(data)
|> Enum.map(&to16/1)
|> Enum.map_reduce("", fn x, s -> {nil, s <> to_b45(x)} end)
|> elem(1)
end
@doc """
Encodes a file (which must be open, the argument has to be a file handle or an atom
such as :stdio)
"""
@spec encode_file(atom | pid) :: String.t
def encode_file(file) do
# Dialyzer claims "The function call will not succeed.
#
# IO.binread(_file :: any(), :all)
#
# breaks the contract
# (device(), :eof | :line | non_neg_integer()) :: iodata() | nodata()
# which seems quite wrong, the documentation of binread clearly authorizes :all
data = IO.binread(file, :all)
case data do
{:error, _reason} -> raise InputOutputError
:eof -> ""
other -> Base45.encode(other)
end
end
@doc """
Decode Base45 strings to binary data. Returns nil if the string is not valid Base45.
## Examples
iex> Base45.decode("%69 VDL2")
"Hello"
iex(1)> Base45.decode("100G5GU5")
<<0, 1, 127, 129, 255>>
iex(1)> Base45.decode("===")
nil
"""
# Unlike encode, decode can fail.
@spec decode(String.t) :: binary
def decode(str) do
decoding(str)
rescue
_e in NilValuesError -> nil
_e in InvalidBase45Error -> nil
end
@doc """
Decode Base45 strings to binary data. Raises an exception NilValuesError if the string is not valid Base45.
## Examples
iex> Base45.decode!("%69 VDL2")
"Hello"
iex(1)> Base45.decode!("===")
** (NilValuesError) nil value(s)
"""
@spec decode!(String.t) :: binary
def decode!(str) do
decoding(str)
# Let the exceptions pass
end
@doc false
@spec from_b45([integer]) :: {integer, integer}
def from_b45([one, two, three]) do
{2, three*45*45 + two*45 + one}
end
def from_b45([one, two]) do
{1, two*45 + one}
end
def from_b45(_other) do
raise InvalidBase45Error, "No proper chunking"
end
@doc false
@spec two_bytes({integer, integer}) :: [byte]
def two_bytes(v) do
{size, n} = v
if size != 1 and size != 2 do
raise "Internal error, #{size} is not an accepted value for size"
end
high = trunc(n/256)
if high >= 256 do
raise InvalidBase45Error, "#{n} is too large"
end
low = trunc(n-256*high)
if size == 2 do
[high, low]
else
[low]
end
end
@doc false
@spec validate_values([any]) :: [integer]
def validate_values(a) do
Enum.map(a,
fn item ->
if is_nil(item) do
raise NilValuesError
else
item
end
end)
a
end
@doc false
@spec decoding(String.t) :: binary
def decoding(str) do
String.graphemes(str)
|> Enum.map(fn c -> @code_to_value[c] end)
|> validate_values
|> Enum.chunk_every(3)
|> Enum.map(&from_b45/1)
|> Enum.map(&two_bytes/1)
|> Enum.map_reduce(<<>>, fn c, acc -> {nil, acc <> :binary.list_to_bin(c)} end)
|> elem(1)
end
@doc false
def encoding(c) do
@value_to_code[c]
end
@doc false
def table([c, d, e]) do
encoding(c) <> encoding(d) <> encoding(e)
end
def table([c, d]) do
encoding(c) <> encoding(d)
end
@doc false
def couples(b) do
case byte_size(b) do
0 -> []
1 -> [[:binary.first(b)]]
_ -> [[:binary.at(b, 0), :binary.at(b, 1)] |
couples(:binary.part(b, {2, byte_size(b) - 2}))]
end
end
@doc false
@spec to_b45({integer, integer}) :: String.t
def to_b45({size, n}) do
e = trunc(n/(45*45))
d = trunc((n - (e*45*45))/45)
c = n - (d*45) - (e*45*45)
case size do
2 -> table([c, d, e])
1 -> table([c, d])
end
end
@doc false
@spec to16([integer]) :: {byte, integer}
def to16([h, l]) do
{2, h*256 + l}
end
def to16([l]) do
{1, l}
end
end