Skip to content

Instantly share code, notes, and snippets.

@jeregrine
Created May 3, 2023 20:33
Show Gist options
  • Save jeregrine/50dc8fca26167b3b4d880855ed69f13d to your computer and use it in GitHub Desktop.
Save jeregrine/50dc8fca26167b3b4d880855ed69f13d to your computer and use it in GitHub Desktop.
Elixir NSID Parser and Validator
defmodule NSID do
@moduledoc """
Grammar:
alpha = "a" / "b" / "c" / "d" / "e" / "f" / "g" / "h" / "i" / "j" / "k" / "l" / "m" / "n" / "o" / "p" / "q" / "r" / "s" / "t" / "u" / "v" / "w" / "x" / "y" / "z" / "A" / "B" / "C" / "D" / "E" / "F" / "G" / "H" / "I" / "J" / "K" / "L" / "M" / "N" / "O" / "P" / "Q" / "R" / "S" / "T" / "U" / "V" / "W" / "X" / "Y" / "Z"
number = "1" / "2" / "3" / "4" / "5" / "6" / "7" / "8" / "9" / "0"
delim = "."
segment = alpha *( alpha / number / "-" )
authority = segment *( delim segment )
name = segment
nsid = authority delim name
nsid-ns = authority delim "*"
"""
import NimbleParsec
defstruct [:authority, :name]
alpha = ascii_string([?a..?z, ?A..?Z], min: 1)
ascii = ascii_string([?a..?z, ?A..?Z, ?0..?9, ?-], min: 1)
delim = string(".")
ns = string("*")
segment =
alpha
|> repeat(ascii)
|> lookahead(choice([delim, eos()]))
|> reduce({Enum, :join, [""]})
authority =
segment
|> ignore(delim)
|> concat(segment)
|> post_traverse({__MODULE__, :check_authority, []})
|> tag(:authority)
name =
choice([ignore(delim), segment, ns])
|> times(min: 1)
|> post_traverse({__MODULE__, :check_name, []})
|> tag(:name)
nsid = authority
|> ignore(delim)
|> concat(name)
|> reduce({__MODULE__, :to_nsid, []})
defparsecp :parser, nsid
def to_nsid(args) do
[auth] = Keyword.get(args, :authority)
[name] = Keyword.get(args, :name)
%NSID{
authority: auth,
name: name
}
end
def check_name(rest, args, context, _line, _offset) do
name = args |> Enum.reverse() |> Enum.join(".")
if String.length(name) > 128 do
{:error, "NSID name part too long (max 128 chars)"}
else
{rest, [name], context}
end
end
def check_authority(rest, args, context, _line, _offset) do
auth = args |> Enum.join(".")
if String.length(auth) > 63 do
{:error, "NSID authority part too long (max 63 chars)"}
else
{rest, [auth], context}
end
end
@doc ~S"""
## Examples
iex> NSID.parse("com.example.bar")
%NSID{authority: "example.com", name: "bar"}
iex> NSID.parse("com.example.*")
%NSID{authority: "example.com", name: "*"}
iex> NSID.parse("com.long-thing1.cool.fooBarBaz")
%NSID{authority: "long-thing1.com", name: "cool.fooBarBaz"}
iex> NSID.parse("onion.g2zyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.lex.deleteThing")
%NSID{authority: "g2zyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion", name: "lex.deleteThing"}
"""
def parse(str) do
{:ok, [val], _, _, _, _} = parser(str)
true = is_valid?(str)
val
end
@doc ~S"""
## Examples
iex> NSID.create("example.com, "bar")
%NSID{authority: "example.com", name: "bar"}
iex> NSID.create("example.com", "*")
%NSID{authority: "example.com", name: "*"}
iex> NSID.create("long-thing1.com", "cool.fooBarBaz")
%NSID{authority: "long-thing1.com", name: "cool.fooBarBaz"}
"""
def create(a, n) do
%NSID{
authority: a,
name: n
}
end
@doc ~S"""
## Examples
iex> NSID.create("example.com", "bar")
%NSID{authority: "example.com", name: "bar"}
iex> NSID.create("example.com", "*")
%NSID{authority: "example.com", name: "*"}
iex> NSID.create("long-thing1.com", "cool.fooBarBaz")
%NSID{authority: "long-thing1.com", name: "cool.fooBarBaz"}
"""
def create(a, n) do
%NSID{
authority: a,
name: n
}
end
@doc ~S"""
## Examples
iex> NSID.to_string(%NSID{authority: "example.com", name: "bar"})
"com.example.bar"
iex> NSID.to_string(%NSID{authority: "example.com", name: "*"})
"com.example.*"
iex> NSID.to_string(%NSID{authority: "long-thing1.com", name: "cool.fooBarBaz"})
"com.long-thing1.cool.fooBarBaz"
"""
defdelegate to_string(uri), to: String.Chars.NSID
@doc ~S"""
## Examples
iex> NSID.is_valid?("com.example.bar")
true
iex> NSID.is_valid?("com.example.*")
true
iex> NSID.is_valid?("com.long-thing1.cool.fooBarBaz")
true
iex> NSID.is_valid?("onion.g2zyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.lex.deleteThing")
true
iex> NSID.is_valid?("cn.8.lex.stuff")
{:error, "expected ASCII character in the range \"a\" to \"z\" or in the range \"A\" to \"Z\" at 8.lex.stuff"}
iex> NSID.is_valid?("example.com")
{:error, "expected string \".\""}
iex> NSID.is_valid?(Enum.join(["com", "ex", Enum.map(0..1000, fn _ -> "P" end)], "."))
{:error, "NSID is too long (382 chars max)"}
iex> NSID.is_valid?(Enum.join(["com", "ex", Enum.map(0..129, fn _ -> "P" end)], "."))
{:error, "NSID name part too long (max 128 chars)"}
"""
def is_valid?(str) when is_binary(str) and byte_size(str) > 382 do
{:error, "NSID is too long (382 chars max)"}
end
def is_valid?(str) do
case parser(str) do
{:ok, _, _, _, _, _} -> true
{:error, msg, str, _, _, _} when is_binary(str) and byte_size(str) == 0 -> {:error, msg}
{:error, msg, char, _, _, _} -> {:error, msg <> " at " <> char}
end
end
end
defimpl String.Chars, for: NSID do
def to_string(%{authority: a, name: n}) do
auth = String.split(a, ".") |> Enum.reverse() |> Enum.intersperse(?.)
IO.iodata_to_binary([
auth, ?., n
])
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment