Created
May 3, 2023 20:33
-
-
Save jeregrine/50dc8fca26167b3b4d880855ed69f13d to your computer and use it in GitHub Desktop.
Elixir NSID Parser and Validator
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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