defmodule DomainName do
@moduledoc """
A module to describe Internet domain names, together with some
useful functions (testing if a domain is a subdomain of another,
etc).
This module does not implement the DNS protocol, it its only for
domain names, independently of how they are used. You may use
packages such as [dns](https://hexdocs.pm/dns/) if you want DNS
abilities.
## Examples
```
iex> d = DomainName.new!("something.example.")
iex> d2 = DomainName.new!("EXAMPLE")
iex> d3 = DomainName.new!("fr")
iex> DomainName.ends_with?(d, d2)
true
iex> DomainName.ends_with?(d, d3)
false
```
"""
@typedoc """
DomainName is is a domain name. It is an opaque type and you should
not access its fields, *much less* modify them.
"""
# https://elixirschool.com/en/lessons/advanced/typespec#defining-custom-type-3
@opaque t :: %__MODULE__{
name: String.t(),
original: String.t(),
labels: [String.t()],
original_labels: [String.t()]
}
# Original means: "original case".
defstruct [:name, :original, :labels, :original_labels]
@hostname_regexp ~r/^[a-z0-9-\.]+$/
defmodule Invalid do
defexception message: "Invalid domain name"
end
# From https://michal.muskala.eu/post/error-handling-in-elixir-libraries/
defmacrop unwrap_or_raise(call) do
quote do
case unquote(call) do
{:ok, value} -> value
{:error, reason} -> raise DomainName.Invalid, reason
end
end
end
@spec new(String.t(), [Keyword.t()]) :: {:ok, DomainName.t()} | {:error, String.t()}
@doc """
Creates a DomainName from a string in dot-separated notation. s is
the string, `options[:must_be_hostname]` indicates if the name must
follow the stricter "host syntax". Currently, the string must be in
pure ASCII (no IDN, see issue #2).
## Examples
```
iex> {:ok, _d} = DomainName.new("toto.fr")
iex> DomainName.new("toto fr", [must_be_hostname: true])
{:error, "Not a hostname (they are restricted to LDH)"}
```
"""
def new(s, options \\ [])
def new(s, options) when not is_nil(s) and is_binary(s) do
options = Keyword.merge([must_be_hostname: false], options)
# Root is a special case
root = s == "."
s =
if String.last(s) == "." do
String.slice(s, 0..(String.length(s) - 2))
else
s
end
idn =
Enum.reduce(String.to_charlist(s), false, fn c, acc ->
if c > 128 do
true
else
acc
end
end)
ds = String.downcase(s)
l = String.split(ds, ".")
original_l = String.split(s, ".")
empty_labels = Enum.filter(l, fn l -> l == "" end) != []
can_be_hostname = String.match?(ds, @hostname_regexp)
cond do
idn ->
{:error,
"We currently don't support IDN, see https://framagit.org/bortzmeyer/domainname-elixir/-/issues/2"}
root ->
{:ok, %DomainName{:name => s, :original => s, :labels => [], :original_labels => []}}
options[:must_be_hostname] and not can_be_hostname ->
{:error, "Not a hostname (they are restricted to LDH)"}
empty_labels ->
{:error, "Two consecutive dots"}
true ->
{:ok,
%DomainName{:name => ds, :original => s, :labels => l, :original_labels => original_l}}
end
end
# For character lists
def new(cl, options) when is_list(cl) do
cl
|> List.to_string()
|> new(options)
end
@spec new!(String.t(), [Keyword.t()]) :: DomainName.t()
@doc """
Creates a DomainName from a string in dot-separated notation and
raises the exception `DomainName.Invalid` if
`options[:must_be_hostname]` is true and the name does not have the
proper syntax. Apart from that, see the documentation for `new/2`.
## Examples
```
iex> _d = DomainName.new!("toto.fr")
iex> _d = DomainName.new!("toto. fr", [must_be_hostname: true])
** (DomainName.Invalid) Not a hostname (they are restricted to LDH)
```
"""
def new!(s, options \\ []) do
unwrap_or_raise(new(s, options))
end
@spec name(DomainName.t()) :: String.t()
@doc """
Returns the domain name as a string.
## Examples
```
iex> d = DomainName.new!("toto.fr")
iex> DomainName.name(d)
"toto.fr"
```
"""
def name(d) do
d.name
end
@spec can_be_hostname?(DomainName.t()) :: boolean
@doc """
Returns true if the domain name can be a host name (stricter syntax, see RFC 1123, section 2.1)
## Examples
```
iex> d = DomainName.new!("example.com")
iex> DomainName.can_be_hostname?(d)
true
iex> d = DomainName.new!("example$.com")
iex> DomainName.can_be_hostname?(d)
false
```
"""
def can_be_hostname?(d) do
String.match?(d.name, @hostname_regexp)
end
@spec original(DomainName.t()) :: String.t()
@doc """
Returns the domain name as a string, in the original case (can be
useful for things like case-randomization by resolvers).
## Examples
```
iex> d = DomainName.new!("toTO.Fr")
iex> DomainName.name(d)
"toto.fr"
iex> DomainName.original(d)
"toTO.Fr"
```
"""
def original(d) do
d.original
end
@spec original_labels(DomainName.t()) :: [String.t()]
@doc """
Returns the labels of the domain name, in the original case (can be
useful for things like case-randomization by resolvers).
## Examples
```
iex> d = DomainName.new!("toTO.Fr")
iex> DomainName.labels(d)
["toto", "fr"]
iex> DomainName.original_labels(d)
["toTO", "Fr"]
```
"""
def original_labels(d) do
d.original_labels
end
@spec equal?(DomainName.t(), DomainName.t()) :: boolean
@doc """
Checks if two domain names are identical (in a case-insensitive way).
## Examples
```
iex> d1 = DomainName.new!("example.com")
iex> d2 = DomainName.new!("example.COM")
iex> DomainName.equal?(d1, d2)
true
iex> d3 = DomainName.new!("example.org")
iex> DomainName.equal?(d1, d3)
false
```
"""
def equal?(l, r) do
l.name == r.name
end
@spec list_ends_with?([any], [any]) :: boolean
@doc false
defp list_ends_with?(l1, []) when is_list(l1) do
true
end
@doc false
defp list_ends_with?(l1, l2) when is_list(l1) and is_list(l2) do
length(l1) >= length(l2) and
(List.last(l1) == List.last(l2) and
list_ends_with?(
elem(List.pop_at(l1, length(l1) - 1), 1),
elem(List.pop_at(l2, length(l2) - 1), 1)
))
end
@spec ends_with?(DomainName.t(), DomainName.t()) :: boolean
@doc """
Checks if a domain is a subdomain of the other one (or a list of
others).
## Examples
```
iex> d1 = DomainName.new!("foobar.example.net")
iex> d2 = DomainName.new!("example.net")
iex> DomainName.ends_with?(d1, d2)
true
iex> d3 = DomainName.new!("org")
iex> DomainName.ends_with?(d1, d3)
false
iex> d = DomainName.new!("truc.machin.chose")
iex> l = [DomainName.new!("example.com"), DomainName.new!("machin.chose"), DomainName.new!("fr")]
iex> DomainName.ends_with?(d, l)
true
iex> l2 = [DomainName.new!("example.com"), DomainName.new!("fr")]
iex> DomainName.ends_with?(d, l2)
false
```
"""
def ends_with?(l = %DomainName{}, r = %DomainName{}) do
list_ends_with?(l.labels, r.labels)
end
def ends_with?(l = %DomainName{}, r) when is_list(r) do
Enum.reduce_while(r, false, fn d, acc ->
if list_ends_with?(l.labels, d.labels) do
{:halt, true}
else
{:cont, acc}
end
end)
end
@spec ends_with(DomainName.t(), [DomainName.t()]) :: {true, DomainName.t()} | false
@doc """
Checks if a domain is a subdomain of one of the domains in the
second parameter (and, if true, returns that parent domain).
## Examples
```
iex> d = DomainName.new!("truc.machin.chose")
iex> l = [DomainName.new!("example.com"), DomainName.new!("machin.chose"), DomainName.new!("fr")]
iex> DomainName.ends_with(d, l)
{true,
%DomainName{
labels: ["machin", "chose"],
name: "machin.chose",
original: "machin.chose",
original_labels: ["machin", "chose"]
}}
iex> l2 = [DomainName.new!("example.com"), DomainName.new!("fr")]
iex> DomainName.ends_with(d, l2)
false
```
"""
# Yes, the return value can be a tuple {true, base} or an unary
# false. See
# <https://elixirforum.com/t/how-is-it-recommended-to-return-result-from-a-boolean-function-which-includes-error-data-in-case-of-fault/>
# for a discussion.
#
# There is no question mark at the end of the function name since
# does not return a boolean.
def ends_with(l = %DomainName{}, r) when is_list(r) do
Enum.reduce_while(r, false, fn d, acc ->
if list_ends_with?(l.labels, d.labels) do
{:halt, {true, d}}
else
{:cont, acc}
end
end)
end
@spec is_one_of(DomainName.t(), [DomainName.t()]) :: {true, DomainName.t()} | false
@doc """
Checks if a domain is equal to one of the domains in the
second parameter (and, if true, returns that domain).
## Examples
```
iex> d = DomainName.new!("machin.chose")
iex> l = [DomainName.new!("example.com"), DomainName.new!("machin.chose"), DomainName.new!("fr")]
iex> DomainName.is_one_of(d, l)
{true,
%DomainName{
labels: ["machin", "chose"],
name: "machin.chose",
original: "machin.chose",
original_labels: ["machin", "chose"]
}}
iex> l2 = [DomainName.new!("example.com"), DomainName.new!("chose")]
iex> DomainName.is_one_of(d, l2)
false
```
"""
def is_one_of(l = %DomainName{}, r) when is_list(r) do
Enum.reduce_while(r, false, fn d, acc ->
if l.name == d.name do
{:halt, {true, d}}
else
{:cont, acc}
end
end)
end
@spec without_suffix(DomainName.t(), DomainName.t()) :: {:ok | :error, DomainName.t()}
@doc """
If a domain is a subdomain of the domain in the second parameter
returns :ok and the short version of the first parameter.
## Examples
```
iex> d = DomainName.new!("truc.machin.chose")
iex> {:ok, s} = DomainName.without_suffix(d, DomainName.new!("machin.chose"))
iex> DomainName.name(s)
"truc"
iex> {:error, _d} = DomainName.without_suffix(d, DomainName.new!("machin.com"))
```
"""
def without_suffix(d, suffix) do
if not ends_with?(d, suffix) do
{:error, d}
else
{:ok, join!(Enum.slice(d.original_labels, 0, length(d.labels) - length(suffix.labels)))}
end
end
@spec labels(DomainName.t()) :: [String.t()]
@doc """
Returns the list of labels in the domain name.
## Examples
```
iex> {:ok, d} = DomainName.new("foo.bar.example.")
iex> DomainName.labels(d)
["foo", "bar", "example"]
```
"""
def labels(d = %DomainName{}) do
d.labels
end
@spec tld(DomainName.t()) :: String.t()
@doc """
Returns the TLD (Top-Level domain) of the domain name.
## Examples
```
iex> {:ok, d} = DomainName.new("foo.bar.example.")
iex> DomainName.tld(d)
"example"
```
"""
def tld(d = %DomainName{}) do
List.last(d.labels)
end
@spec join(String.t(), DomainName.t() | [String.t()]) ::
{:ok, DomainName.t()} | {:error, String.t()}
@doc """
Returns the domain name from the subdomain `s` of the domain `d`.
## Examples
```
iex> d = DomainName.new!("thing.example")
iex> {:ok, r} = DomainName.join("some", d)
iex> DomainName.name(r)
"some.thing.example"
```
"""
def join(s, d = %DomainName{}) when is_binary(s) do
join(labels(new!(s)) ++ labels(d))
end
@doc """
Returns the domain name from the list of labels `l`.
## Examples
```
iex> {:ok, r} = DomainName.join(["some", "thing", "example"])
iex> DomainName.name(r)
"some.thing.example"
```
"""
# The empty list is a special case for when without_suffix returns
# an empty name.
def join([]) do
{:ok, %DomainName{:name => "", :original => "", :labels => [], :original_labels => []}}
end
def join(l) when is_list(l) do
new(
Enum.reduce(l, "", fn label, acc ->
if acc == "" do
label
else
acc <> "." <> label
end
end)
)
end
@spec join!(String.t(), DomainName.t()) :: DomainName.t()
@doc """
Returns the domain name from the subdomain `s` of the domain `d`,
and raises an exception if there is a problem.
## Examples
```
iex> d = DomainName.new!("thing.example")
iex> r = DomainName.join!("some", d)
iex> DomainName.name(r)
"some.thing.example"
```
"""
def join!(s, d = %DomainName{}) when is_binary(s) do
unwrap_or_raise(join(s, d))
end
@spec join!([String.t()]) :: DomainName.t()
@doc """
Returns the domain name from the list of labels `l`, and raises an
exception if there is a problem.
## Examples
```
iex> r = DomainName.join!(["some", "thing", "example"])
iex> DomainName.name(r)
"some.thing.example"
```
"""
def join!(l) when is_list(l) do
unwrap_or_raise(join(l))
end
# The functions `encode` and `decode` are useful only when dealing with the DNS.
@doc """
Encode a domain name in DNS wire format.
"""
@spec encode(DomainName.t(), [Keyword.t()]) :: [integer]
def encode(d = %DomainName{}, options \\ []) do
options = Keyword.merge([original: false], options)
labels =
if options[:original] do
d.original_labels
else
d.labels
end
root = [0]
cond do
# The domain name root is special
labels == [] ->
root
true ->
l =
Enum.reduce(labels, [], fn label, acc ->
acc ++ [byte_size(label)] ++ to_charlist(label)
end)
l ++ root
end
end
@spec decode([integer]) :: {:ok, DomainName.t()} | {:error, String.t()}
@doc """
Decode a domain name from the list of bytes in its wire representation.
"""
def decode(l) when is_list(l) do
result =
Enum.reduce(l, {:next, {1, ""}}, fn c, acc ->
if c > 128 do
{:error, "We currently don't handle names with the 8th bit set"}
else
case acc do
{:next, {_i, s}} ->
{:label, {c, s}}
{:label, {i, s}} ->
case i do
0 ->
raise RuntimeError
1 ->
{:next, {1, s <> <<c::utf8>> <> "."}}
_ ->
{:label, {i - 1, s <> <<c::utf8>>}}
end
{:error, reason} ->
{:error, reason}
end
end
end)
case result do
{:label, {i, s}} ->
if i != 0 do
{:error, "Invalid byte string for a domain name"}
else
new(s)
end
{:next, _} ->
{:error, "Things missing in the byte string?"}
{:error, reason} ->
{:error, reason}
end
end
@spec decode!([integer]) :: DomainName.t()
@doc """
Decode a domain name from the list of bytes in its wire
representation, and raises an exception if it is not a proper
encoding.
"""
def decode!(l) when is_list(l) do
unwrap_or_raise(decode(l))
end
defimpl String.Chars, for: DomainName do
def to_string(d) do
d.name
end
end
defimpl List.Chars, for: DomainName do
def to_charlist(d) do
Kernel.to_charlist(d.name)
end
end
defimpl Inspect, for: DomainName do
def inspect(d, _options) do
"#DomainName<#{d.name}>"
end
end
end