diff --git a/.github/workflows/erlang.yml b/.github/workflows/erlang.yml new file mode 100644 index 0000000..2819851 --- /dev/null +++ b/.github/workflows/erlang.yml @@ -0,0 +1,54 @@ +name: Erlang CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + + erlang24: + + runs-on: ubuntu-latest + + container: + image: erlang:24 + + steps: + - uses: actions/checkout@v3 + - name: Compile + run: rebar3 compile + - name: Run tests + run: rebar3 do eunit, ct + + erlang25: + + runs-on: ubuntu-latest + + container: + image: erlang:25 + + steps: + - uses: actions/checkout@v3 + - name: Compile + run: rebar3 compile + - name: Run tests + run: rebar3 do eunit, ct + + erlang26: + + runs-on: ubuntu-latest + + container: + image: erlang:26 + + steps: + - uses: actions/checkout@v3 + - name: Compile + run: rebar3 compile + - name: Run tests + run: rebar3 do eunit, ct diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f1c4554 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +.rebar3 +_* +.eunit +*.o +*.beam +*.plt +*.swp +*.swo +.erlang.cookie +ebin +log +erl_crash.dump +.rebar +logs +_build +.idea +*.iml +rebar3.crashdump +*~ diff --git a/CHANGELOG.md b/CHANGELOG.md index f318ac7..b7a5a74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,5 @@ # CHANGELOG -@todo \ No newline at end of file +## UNRELEASED 0.1.0 +### New Feature +- Initial implementation diff --git a/README.md b/README.md index 84e69bb..c5aa1d5 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,34 @@ Sqids (pronounced "squids") is a small library that lets you generate YouTube-lo ## Getting started -@todo +Add sqids as a dependency to rebar.config + +```erlang +{deps, + [ + {sqids, {git, "https://github.com/sqids/sqids-erlang.git", {tag, "0.1.0"}}} + ] +}. +``` +Supported Erlang versions are + - Erlang/OTP 24 + - Erlang/OTP 25 + - Erlang/OTP 26 ## Examples -@todo +```erlang +1> Sqids = sqids:new(), +1> Id = sqids:encode([1, 2, 3], Sqids). +<<"86Rf07">> +2> sqids:decode(Id, Sqids). +[1,2,3] +3> MySqids = sqids:new(#{alphabet=><<"ABC123">>, min_length=>10, blocklist=>[]}), +3> MyId = sqids:encode([0], MySqids). +<<"A13C2B31AC">> +4> sqids:decode(MyId, MySqids). +[0] +``` ## License diff --git a/rebar.config b/rebar.config new file mode 100644 index 0000000..f618f3e --- /dev/null +++ b/rebar.config @@ -0,0 +1,2 @@ +{erl_opts, [debug_info]}. +{deps, []}. \ No newline at end of file diff --git a/rebar.lock b/rebar.lock new file mode 100644 index 0000000..57afcca --- /dev/null +++ b/rebar.lock @@ -0,0 +1 @@ +[]. diff --git a/src/sqids.app.src b/src/sqids.app.src new file mode 100644 index 0000000..7b82dc6 --- /dev/null +++ b/src/sqids.app.src @@ -0,0 +1,14 @@ +{application, sqids, + [{description, "An OTP library"}, + {vsn, "0.1.0"}, + {registered, []}, + {applications, + [kernel, + stdlib + ]}, + {env,[]}, + {modules, []}, + + {licenses, ["MIT License"]}, + {links, []} + ]}. diff --git a/src/sqids.erl b/src/sqids.erl new file mode 100644 index 0000000..d8a80db --- /dev/null +++ b/src/sqids.erl @@ -0,0 +1,369 @@ +-module(sqids). + +-export([ + new/0 + , new/1 + , default_options/0 + , encode/2 + , decode/2 + ]). + +-export_type([ + options/0 + , str/0 + , blocklist/0 + , sqids/0 + ]). + +% String representations supported in this module is/are: +% - non multibyte binary format +-type str() :: unicode:latin1_binary(). + +-type char_() :: <<_:8>>. + +-type blocklist() :: lists:list(str()). + +-type options() :: #{ + alphabet => str() + , min_length => non_neg_integer() + , blocklist => blocklist() + }. + +-opaque sqids() :: #{ + '?MODULE' := ?MODULE + , alphabet := str() + , min_length := non_neg_integer() + , blocklist := blocklist() + , n := non_neg_integer() + }. + +-spec default_options() -> options(). +default_options() -> + #{ alphabet => + <<"abcdefghijklmnopqrstuvwxyz", + "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + "0123456789">> + , min_length => 0 + , blocklist => sqids_blocklist:get() + }. + +-spec new() -> sqids(). +new() -> + new(#{}). + +-spec new(options()) -> sqids(). +new(Options0) when is_map(Options0)-> + Options = maps:merge(default_options(), Options0), + case Options of + #{ alphabet := Alphabet + , min_length := MinLength + , blocklist := Blocklist + } when is_binary(Alphabet) + , is_integer(MinLength) + , MinLength >= 0 % no upper limit. + , is_list(Blocklist) -> ok; + _ -> + erlang:error(badarg, [Options0]) + end, + BinAlphabet = maps:get(alphabet, Options), + ListAlphabet = binary_to_list(BinAlphabet), + Is7Bit = unicode:bin_is_7bit(BinAlphabet), + case {size(BinAlphabet), Is7Bit} of + {Size, true} when Size < 3 -> + Reason0 = 'Alphabet length must be at least 3', + erlang:error(Reason0, [Options0]); + {_Size, true} -> + ok; + _ -> + Reason0 = 'Alphabet cannot contain multibyte characters', + erlang:error(Reason0, [Options0]) + end, + SetsOpt = [{version, 2}], + SetAlphabet = sets:from_list(ListAlphabet, SetsOpt), + case {size(BinAlphabet), sets:size(SetAlphabet)} of + {SetSize, SetSize} -> + ok; + _ -> + Reason1 = 'Alphabet must contain unique characters', + erlang:error(Reason1, [Options0]) + end, + AlphabetLowercased = string:casefold(BinAlphabet), + AlphabetCharSet = str_to_char_set(AlphabetLowercased), + FilteredBlocklist = lists:filtermap(fun + (Word) when size(Word) >= 3-> + WordLowercased = string:casefold(Word), + WordChars = str_to_char_list(WordLowercased), + try + lists:foreach(fun + (C) -> + case sets:is_element(C, AlphabetCharSet) of + true -> ok; + false -> throw({?MODULE, break}) + end + end, WordChars) + of + ok -> + {true, WordLowercased} + catch + throw:{?MODULE, break} -> + false + end; + (_) -> + false + end, maps:get(blocklist, Options)), + UniqueFilteredBlocklist = sets:to_list( + sets:from_list(FilteredBlocklist, SetsOpt) + ), + #{ '?MODULE' => ?MODULE + , alphabet => shuffle(BinAlphabet) + , min_length => maps:get(min_length, Options) + , blocklist => UniqueFilteredBlocklist + , n => size(BinAlphabet) + } ; +new(Options0) -> + erlang:error(badarg, [Options0]). + + +-spec encode([non_neg_integer()], sqids()) -> str(). +encode([], #{'?MODULE':=?MODULE}) -> + <<>>; +encode(Numbers, Sqids=#{'?MODULE':=?MODULE}) -> + lists:foreach(fun + (Num) when is_integer(Num) andalso Num >= 0 -> + ok; + (_) -> + erlang:error(badarg, [Numbers, Sqids]) + end, Numbers), + encode_numbers(Numbers, 0, Sqids); +encode(Arg1, Arg2) -> + erlang:error(badarg, [Arg1, Arg2]). + +-spec encode_numbers( + [non_neg_integer()], non_neg_integer(), sqids() + ) -> str(). +encode_numbers(Num, Inc, #{n:=N}=Sqids) when Inc > N -> + Reason = 'Reached max attempts to re-generate the ID', + erlang:error(Reason, [Num, Inc, Sqids]); +encode_numbers(Numbers, Increment, Sqids) -> + This = fun(Key) -> maps:get(Key, Sqids) end, + {_i, Offset0} = lists:foldl(fun(V, {I, A})-> + Next = binary:at(This(alphabet), V rem This(n)) + I + A, + {I+1, Next} + end, {0, length(Numbers)}, Numbers), + Offset1 = Offset0 rem This(n), + Offset = (Offset1 + Increment) rem This(n), + <> = This(alphabet), + Alphabet0 = <>, + Prefix = binary:at(Alphabet0, 0), + Alphabet1 = list_to_binary(lists:reverse(binary_to_list(Alphabet0))), + {RevCharList0, Alphabet2} = encode_input_array( + Numbers, [Prefix], Alphabet1 + ), + Id = case This(min_length) of + MinLength when MinLength > length(RevCharList0) -> + Separator = binary:at(Alphabet2, 0), + RevCharList1 = [Separator|RevCharList0], + Id0 = list_to_binary(lists:reverse(RevCharList1)), + id_padding(Id0, This(min_length), Alphabet2); + _ -> + list_to_binary(lists:reverse(RevCharList0)) + end, + case is_blocked_id(Id, This(blocklist)) of + false -> + Id; + _ -> + encode_numbers(Numbers, Increment+1, Sqids) + end. + +-spec encode_input_array( + [non_neg_integer(), ...], [non_neg_integer(), ...], str() + ) -> {[char_(), ...], str()}. +encode_input_array([Num], Id0, Alphabet) -> + <<_:1/binary, AlphabetWithoutSeparator/binary>> = Alphabet, + Id1 = [to_id(Num, AlphabetWithoutSeparator)|Id0], + {Id1, Alphabet}; +encode_input_array([Num|Numbers], Id0, Alphabet) -> + <> = Alphabet, + Id1 = [to_id(Num, AlphabetWithoutSeparator)|Id0], + Id2 = [Separator|Id1], + encode_input_array(Numbers, Id2, shuffle(Alphabet)). + +id_padding(Id0, MinLength, Alphabet0) when MinLength - size(Id0) > 0 -> + Alphabet = shuffle(Alphabet0), + Size = min(MinLength-size(Id0), size(Alphabet)), + <> = Alphabet, + Id = <>, + id_padding(Id, MinLength, Alphabet); +id_padding(Id, _MinLength, _Alphabet) -> + Id. + +-spec decode(str(), sqids()) -> [non_neg_integer()]. +decode(<<>>, #{'?MODULE':=?MODULE}) -> + []; +decode(Id, Sqids=#{'?MODULE':=?MODULE}) when is_binary(Id) -> + try + decode_(Id, Sqids) + of + Ret -> Ret + catch + {?MODULE, return, Ret} -> Ret + end; +decode(Arg1, Arg2) -> + erlang:error(badarg, [Arg1, Arg2]). + +-spec decode_(str(), sqids()) -> [non_neg_integer()]. +decode_(Id0, Sqids) -> + This = fun(Key) -> maps:get(Key, Sqids) end, + lists:foreach(fun(C) -> + case sets:is_element(C, str_to_char_set(This(alphabet))) of + true -> ok; + _ -> throw({?MODULE, return, []}) + end + end, str_to_char_list(Id0)), + <> = Id0, + {Offset, _} = binary:match(This(alphabet), Prefix), + <> = This(alphabet), + Alphabet0 = <>, + Alphabet1 = list_to_binary(lists:reverse(binary_to_list(Alphabet0))), + decode_([Id1], Alphabet1, []). + +-spec decode_(MaybeId, str(), sqids()) -> [non_neg_integer()] + when MaybeId :: nil() | nonempty_list(str()). % [] or [<<...>>] +decode_([], _, Ret) -> + lists:reverse(Ret); +decode_([Id0], Alphabet0, Ret0) -> + <> = Alphabet0, + Chunks = binary:split(Id0, Separator), + case Chunks of + [<<>>|_] -> + lists:reverse(Ret0); + [Chunk|Id1] -> + Ret1 = [to_number(Chunk, AlphabetWithoutSeparator)|Ret0], + Alphabet1 = case length(Chunks) of + ChunksLength when ChunksLength > 1 -> + shuffle(Alphabet0); + _ -> + Alphabet0 + end, + decode_(Id1, Alphabet1, Ret1) + end. + +-spec shuffle(str()) -> str(). +shuffle(Alphabet) -> + shuffle_(0, size(Alphabet)-1, Alphabet). + +-spec shuffle_(non_neg_integer(), non_neg_integer(), str()) -> str(). +shuffle_(_, 0, Alphabet) -> + Alphabet; +shuffle_(I, J, Alphabet) -> + N = size(Alphabet), + R = (I * J + binary:at(Alphabet, I) + binary:at(Alphabet, J)) rem N, + shuffle_(I+1, J-1, swap(I, R, Alphabet)). + +-spec swap(non_neg_integer(), non_neg_integer(), str()) -> str(). +swap(X, X, BinaryString) -> + BinaryString; +swap(Y, X, BinaryString) when Y > X -> + swap(X, Y, BinaryString); +swap(X, Y, BinaryString) when ( + is_integer(X) andalso X >= 0 andalso + is_integer(Y) andalso Y >= 0 andalso + is_binary(BinaryString) + ) -> + PrefixSize = X, + LeftSize = 1, + InterfixSize = Y - X -1, + RightSize = 1, + << Prefix:PrefixSize/binary + , Left:LeftSize/binary + , Interfix:InterfixSize/binary + , Right:RightSize/binary + , Suffix/binary + >> = BinaryString, + << Prefix/binary + , Right/binary + , Interfix/binary + , Left/binary + , Suffix/binary + >>; +swap(Arg1, Arg2, Arg3) -> + erlang:error(badarg, [Arg1, Arg2, Arg3]). + +-spec to_id(non_neg_integer(), str()) -> char_(). +to_id(Num, Alphabet) -> + to_id_(Num, Alphabet, <<>>). + +-spec to_id_(non_neg_integer(), str(), str()) -> str(). +to_id_(0, _, Id) when size(Id) > 0 -> + Id; +to_id_(Num0, Alphabet, Id0) -> + Char = binary:at(Alphabet, Num0 rem size(Alphabet)), + Id1 = <>, + Num1 = Num0 div size(Alphabet), + to_id_(Num1, Alphabet, Id1). + +-spec to_number(str(), str()) -> non_neg_integer(). +to_number(Id, Alphabet) -> + lists:foldl(fun(V, A) -> + {Index, _} = binary:match(Alphabet, <>), + A * size(Alphabet) + Index + end, 0, binary_to_list(Id)). + +-spec is_blocked_id(str(), blocklist()) -> boolean(). +is_blocked_id(Id0, Blocklist) -> + Id = string:casefold(Id0), + try + lists:foreach(fun(Word) -> + case is_blocked_id_(Word, Id) of + true -> throw({?MODULE, return, true}); + false -> ok + end + end, Blocklist) + of + _ -> + false + catch + throw:{?MODULE, return, true} -> + true + end. + +-spec is_blocked_id_(Word::str(), Id::str()) -> boolean(). +is_blocked_id_(Word, Id) when size(Word) =< 3 orelse size(Id) =< 3 -> + (Word =:= Id); +is_blocked_id_(Word, Id) -> + case re:run(Word, <<"\\d">>) of + nomatch -> + case binary:match(Id, Word) of + nomatch -> false; + _ -> true + end; + _ -> + RevWord = reverse_str(Word), + W = size(Word), + case {Id, reverse_str(Id)} of + {<>, _} -> true; + {_, <>} -> true; + _ -> false + end + end. + +-spec str_to_char_set(str()) -> sets:set(char_()). +str_to_char_set(Str) -> + List = str_to_char_list(Str), + sets:from_list(List, [{version, 2}]). + +-spec str_to_char_list(str()) -> lists:list(char_()). +str_to_char_list(Str) -> + lists:map(fun(Char) -> + <> + end, binary_to_list(Str)). + +-spec reverse_str(str()) -> str(). +reverse_str(Str) -> + reverse_str_(Str, <<>>). + +-spec reverse_str_(str(), str()) -> str(). +reverse_str_(<<>>, Str) -> Str; +reverse_str_(<>, Str) -> + reverse_str_(Tail, <>). + diff --git a/src/sqids_blocklist.erl b/src/sqids_blocklist.erl new file mode 100644 index 0000000..b5c05b4 --- /dev/null +++ b/src/sqids_blocklist.erl @@ -0,0 +1,575 @@ +-module(sqids_blocklist). + +-export([ + get/0 + ]). + +-export_type([ + blocklist/0 + ]). + +-type blocklist() :: lists:list(unicode:latin1_binary()). + +-spec get() -> blocklist(). +get() -> + [ <<"0rgasm">> + , <<"1d10t">> + , <<"1d1ot">> + , <<"1di0t">> + , <<"1diot">> + , <<"1eccacu10">> + , <<"1eccacu1o">> + , <<"1eccacul0">> + , <<"1eccaculo">> + , <<"1mbec11e">> + , <<"1mbec1le">> + , <<"1mbeci1e">> + , <<"1mbecile">> + , <<"a11upat0">> + , <<"a11upato">> + , <<"a1lupat0">> + , <<"a1lupato">> + , <<"aand">> + , <<"ah01e">> + , <<"ah0le">> + , <<"aho1e">> + , <<"ahole">> + , <<"al1upat0">> + , <<"al1upato">> + , <<"allupat0">> + , <<"allupato">> + , <<"ana1">> + , <<"ana1e">> + , <<"anal">> + , <<"anale">> + , <<"anus">> + , <<"arrapat0">> + , <<"arrapato">> + , <<"arsch">> + , <<"arse">> + , <<"ass">> + , <<"b00b">> + , <<"b00be">> + , <<"b01ata">> + , <<"b0ceta">> + , <<"b0iata">> + , <<"b0ob">> + , <<"b0obe">> + , <<"b0sta">> + , <<"b1tch">> + , <<"b1te">> + , <<"b1tte">> + , <<"ba1atkar">> + , <<"balatkar">> + , <<"bastard0">> + , <<"bastardo">> + , <<"batt0na">> + , <<"battona">> + , <<"bitch">> + , <<"bite">> + , <<"bitte">> + , <<"bo0b">> + , <<"bo0be">> + , <<"bo1ata">> + , <<"boceta">> + , <<"boiata">> + , <<"boob">> + , <<"boobe">> + , <<"bosta">> + , <<"bran1age">> + , <<"bran1er">> + , <<"bran1ette">> + , <<"bran1eur">> + , <<"bran1euse">> + , <<"branlage">> + , <<"branler">> + , <<"branlette">> + , <<"branleur">> + , <<"branleuse">> + , <<"c0ck">> + , <<"c0g110ne">> + , <<"c0g11one">> + , <<"c0g1i0ne">> + , <<"c0g1ione">> + , <<"c0gl10ne">> + , <<"c0gl1one">> + , <<"c0gli0ne">> + , <<"c0glione">> + , <<"c0na">> + , <<"c0nnard">> + , <<"c0nnasse">> + , <<"c0nne">> + , <<"c0u111es">> + , <<"c0u11les">> + , <<"c0u1l1es">> + , <<"c0u1lles">> + , <<"c0ui11es">> + , <<"c0ui1les">> + , <<"c0uil1es">> + , <<"c0uilles">> + , <<"c11t">> + , <<"c11t0">> + , <<"c11to">> + , <<"c1it">> + , <<"c1it0">> + , <<"c1ito">> + , <<"cabr0n">> + , <<"cabra0">> + , <<"cabrao">> + , <<"cabron">> + , <<"caca">> + , <<"cacca">> + , <<"cacete">> + , <<"cagante">> + , <<"cagar">> + , <<"cagare">> + , <<"cagna">> + , <<"cara1h0">> + , <<"cara1ho">> + , <<"caracu10">> + , <<"caracu1o">> + , <<"caracul0">> + , <<"caraculo">> + , <<"caralh0">> + , <<"caralho">> + , <<"cazz0">> + , <<"cazz1mma">> + , <<"cazzata">> + , <<"cazzimma">> + , <<"cazzo">> + , <<"ch00t1a">> + , <<"ch00t1ya">> + , <<"ch00tia">> + , <<"ch00tiya">> + , <<"ch0d">> + , <<"ch0ot1a">> + , <<"ch0ot1ya">> + , <<"ch0otia">> + , <<"ch0otiya">> + , <<"ch1asse">> + , <<"ch1avata">> + , <<"ch1er">> + , <<"ch1ng0">> + , <<"ch1ngadaz0s">> + , <<"ch1ngadazos">> + , <<"ch1ngader1ta">> + , <<"ch1ngaderita">> + , <<"ch1ngar">> + , <<"ch1ngo">> + , <<"ch1ngues">> + , <<"ch1nk">> + , <<"chatte">> + , <<"chiasse">> + , <<"chiavata">> + , <<"chier">> + , <<"ching0">> + , <<"chingadaz0s">> + , <<"chingadazos">> + , <<"chingader1ta">> + , <<"chingaderita">> + , <<"chingar">> + , <<"chingo">> + , <<"chingues">> + , <<"chink">> + , <<"cho0t1a">> + , <<"cho0t1ya">> + , <<"cho0tia">> + , <<"cho0tiya">> + , <<"chod">> + , <<"choot1a">> + , <<"choot1ya">> + , <<"chootia">> + , <<"chootiya">> + , <<"cl1t">> + , <<"cl1t0">> + , <<"cl1to">> + , <<"clit">> + , <<"clit0">> + , <<"clito">> + , <<"cock">> + , <<"cog110ne">> + , <<"cog11one">> + , <<"cog1i0ne">> + , <<"cog1ione">> + , <<"cogl10ne">> + , <<"cogl1one">> + , <<"cogli0ne">> + , <<"coglione">> + , <<"cona">> + , <<"connard">> + , <<"connasse">> + , <<"conne">> + , <<"cou111es">> + , <<"cou11les">> + , <<"cou1l1es">> + , <<"cou1lles">> + , <<"coui11es">> + , <<"coui1les">> + , <<"couil1es">> + , <<"couilles">> + , <<"cracker">> + , <<"crap">> + , <<"cu10">> + , <<"cu1att0ne">> + , <<"cu1attone">> + , <<"cu1er0">> + , <<"cu1ero">> + , <<"cu1o">> + , <<"cul0">> + , <<"culatt0ne">> + , <<"culattone">> + , <<"culer0">> + , <<"culero">> + , <<"culo">> + , <<"cum">> + , <<"cunt">> + , <<"d11d0">> + , <<"d11do">> + , <<"d1ck">> + , <<"d1ld0">> + , <<"d1ldo">> + , <<"damn">> + , <<"de1ch">> + , <<"deich">> + , <<"depp">> + , <<"di1d0">> + , <<"di1do">> + , <<"dick">> + , <<"dild0">> + , <<"dildo">> + , <<"dyke">> + , <<"encu1e">> + , <<"encule">> + , <<"enema">> + , <<"enf01re">> + , <<"enf0ire">> + , <<"enfo1re">> + , <<"enfoire">> + , <<"estup1d0">> + , <<"estup1do">> + , <<"estupid0">> + , <<"estupido">> + , <<"etr0n">> + , <<"etron">> + , <<"f0da">> + , <<"f0der">> + , <<"f0ttere">> + , <<"f0tters1">> + , <<"f0ttersi">> + , <<"f0tze">> + , <<"f0utre">> + , <<"f1ca">> + , <<"f1cker">> + , <<"f1ga">> + , <<"fag">> + , <<"fica">> + , <<"ficker">> + , <<"figa">> + , <<"foda">> + , <<"foder">> + , <<"fottere">> + , <<"fotters1">> + , <<"fottersi">> + , <<"fotze">> + , <<"foutre">> + , <<"fr0c10">> + , <<"fr0c1o">> + , <<"fr0ci0">> + , <<"fr0cio">> + , <<"fr0sc10">> + , <<"fr0sc1o">> + , <<"fr0sci0">> + , <<"fr0scio">> + , <<"froc10">> + , <<"froc1o">> + , <<"froci0">> + , <<"frocio">> + , <<"frosc10">> + , <<"frosc1o">> + , <<"frosci0">> + , <<"froscio">> + , <<"fuck">> + , <<"g00">> + , <<"g0o">> + , <<"g0u1ne">> + , <<"g0uine">> + , <<"gandu">> + , <<"go0">> + , <<"goo">> + , <<"gou1ne">> + , <<"gouine">> + , <<"gr0gnasse">> + , <<"grognasse">> + , <<"haram1">> + , <<"harami">> + , <<"haramzade">> + , <<"hund1n">> + , <<"hundin">> + , <<"id10t">> + , <<"id1ot">> + , <<"idi0t">> + , <<"idiot">> + , <<"imbec11e">> + , <<"imbec1le">> + , <<"imbeci1e">> + , <<"imbecile">> + , <<"j1zz">> + , <<"jerk">> + , <<"jizz">> + , <<"k1ke">> + , <<"kam1ne">> + , <<"kamine">> + , <<"kike">> + , <<"leccacu10">> + , <<"leccacu1o">> + , <<"leccacul0">> + , <<"leccaculo">> + , <<"m1erda">> + , <<"m1gn0tta">> + , <<"m1gnotta">> + , <<"m1nch1a">> + , <<"m1nchia">> + , <<"m1st">> + , <<"mam0n">> + , <<"mamahuev0">> + , <<"mamahuevo">> + , <<"mamon">> + , <<"masturbat10n">> + , <<"masturbat1on">> + , <<"masturbate">> + , <<"masturbati0n">> + , <<"masturbation">> + , <<"merd0s0">> + , <<"merd0so">> + , <<"merda">> + , <<"merde">> + , <<"merdos0">> + , <<"merdoso">> + , <<"mierda">> + , <<"mign0tta">> + , <<"mignotta">> + , <<"minch1a">> + , <<"minchia">> + , <<"mist">> + , <<"musch1">> + , <<"muschi">> + , <<"n1gger">> + , <<"neger">> + , <<"negr0">> + , <<"negre">> + , <<"negro">> + , <<"nerch1a">> + , <<"nerchia">> + , <<"nigger">> + , <<"orgasm">> + , <<"p00p">> + , <<"p011a">> + , <<"p01la">> + , <<"p0l1a">> + , <<"p0lla">> + , <<"p0mp1n0">> + , <<"p0mp1no">> + , <<"p0mpin0">> + , <<"p0mpino">> + , <<"p0op">> + , <<"p0rca">> + , <<"p0rn">> + , <<"p0rra">> + , <<"p0uff1asse">> + , <<"p0uffiasse">> + , <<"p1p1">> + , <<"p1pi">> + , <<"p1r1a">> + , <<"p1rla">> + , <<"p1sc10">> + , <<"p1sc1o">> + , <<"p1sci0">> + , <<"p1scio">> + , <<"p1sser">> + , <<"pa11e">> + , <<"pa1le">> + , <<"pal1e">> + , <<"palle">> + , <<"pane1e1r0">> + , <<"pane1e1ro">> + , <<"pane1eir0">> + , <<"pane1eiro">> + , <<"panele1r0">> + , <<"panele1ro">> + , <<"paneleir0">> + , <<"paneleiro">> + , <<"patakha">> + , <<"pec0r1na">> + , <<"pec0rina">> + , <<"pecor1na">> + , <<"pecorina">> + , <<"pen1s">> + , <<"pendej0">> + , <<"pendejo">> + , <<"penis">> + , <<"pip1">> + , <<"pipi">> + , <<"pir1a">> + , <<"pirla">> + , <<"pisc10">> + , <<"pisc1o">> + , <<"pisci0">> + , <<"piscio">> + , <<"pisser">> + , <<"po0p">> + , <<"po11a">> + , <<"po1la">> + , <<"pol1a">> + , <<"polla">> + , <<"pomp1n0">> + , <<"pomp1no">> + , <<"pompin0">> + , <<"pompino">> + , <<"poop">> + , <<"porca">> + , <<"porn">> + , <<"porra">> + , <<"pouff1asse">> + , <<"pouffiasse">> + , <<"pr1ck">> + , <<"prick">> + , <<"pussy">> + , <<"put1za">> + , <<"puta">> + , <<"puta1n">> + , <<"putain">> + , <<"pute">> + , <<"putiza">> + , <<"puttana">> + , <<"queca">> + , <<"r0mp1ba11e">> + , <<"r0mp1ba1le">> + , <<"r0mp1bal1e">> + , <<"r0mp1balle">> + , <<"r0mpiba11e">> + , <<"r0mpiba1le">> + , <<"r0mpibal1e">> + , <<"r0mpiballe">> + , <<"rand1">> + , <<"randi">> + , <<"rape">> + , <<"recch10ne">> + , <<"recch1one">> + , <<"recchi0ne">> + , <<"recchione">> + , <<"retard">> + , <<"romp1ba11e">> + , <<"romp1ba1le">> + , <<"romp1bal1e">> + , <<"romp1balle">> + , <<"rompiba11e">> + , <<"rompiba1le">> + , <<"rompibal1e">> + , <<"rompiballe">> + , <<"ruff1an0">> + , <<"ruff1ano">> + , <<"ruffian0">> + , <<"ruffiano">> + , <<"s1ut">> + , <<"sa10pe">> + , <<"sa1aud">> + , <<"sa1ope">> + , <<"sacanagem">> + , <<"sal0pe">> + , <<"salaud">> + , <<"salope">> + , <<"saugnapf">> + , <<"sb0rr0ne">> + , <<"sb0rra">> + , <<"sb0rrone">> + , <<"sbattere">> + , <<"sbatters1">> + , <<"sbattersi">> + , <<"sborr0ne">> + , <<"sborra">> + , <<"sborrone">> + , <<"sc0pare">> + , <<"sc0pata">> + , <<"sch1ampe">> + , <<"sche1se">> + , <<"sche1sse">> + , <<"scheise">> + , <<"scheisse">> + , <<"schlampe">> + , <<"schwachs1nn1g">> + , <<"schwachs1nnig">> + , <<"schwachsinn1g">> + , <<"schwachsinnig">> + , <<"schwanz">> + , <<"scopare">> + , <<"scopata">> + , <<"sexy">> + , <<"sh1t">> + , <<"shit">> + , <<"slut">> + , <<"sp0mp1nare">> + , <<"sp0mpinare">> + , <<"spomp1nare">> + , <<"spompinare">> + , <<"str0nz0">> + , <<"str0nza">> + , <<"str0nzo">> + , <<"stronz0">> + , <<"stronza">> + , <<"stronzo">> + , <<"stup1d">> + , <<"stupid">> + , <<"succh1am1">> + , <<"succh1ami">> + , <<"succhiam1">> + , <<"succhiami">> + , <<"sucker">> + , <<"t0pa">> + , <<"tapette">> + , <<"test1c1e">> + , <<"test1cle">> + , <<"testic1e">> + , <<"testicle">> + , <<"tette">> + , <<"topa">> + , <<"tr01a">> + , <<"tr0ia">> + , <<"tr0mbare">> + , <<"tr1ng1er">> + , <<"tr1ngler">> + , <<"tring1er">> + , <<"tringler">> + , <<"tro1a">> + , <<"troia">> + , <<"trombare">> + , <<"turd">> + , <<"twat">> + , <<"vaffancu10">> + , <<"vaffancu1o">> + , <<"vaffancul0">> + , <<"vaffanculo">> + , <<"vag1na">> + , <<"vagina">> + , <<"verdammt">> + , <<"verga">> + , <<"w1chsen">> + , <<"wank">> + , <<"wichsen">> + , <<"x0ch0ta">> + , <<"x0chota">> + , <<"xana">> + , <<"xoch0ta">> + , <<"xochota">> + , <<"z0cc01a">> + , <<"z0cc0la">> + , <<"z0cco1a">> + , <<"z0ccola">> + , <<"z1z1">> + , <<"z1zi">> + , <<"ziz1">> + , <<"zizi">> + , <<"zocc01a">> + , <<"zocc0la">> + , <<"zocco1a">> + , <<"zoccola">> + ] . diff --git a/test/sqids_alphabet_tests.erl b/test/sqids_alphabet_tests.erl new file mode 100644 index 0000000..6571b41 --- /dev/null +++ b/test/sqids_alphabet_tests.erl @@ -0,0 +1,58 @@ +-module(sqids_alphabet_tests). +-include_lib("eunit/include/eunit.hrl"). +-import(sqids, [new/0, new/1, encode/2, decode/2]). + +-compile([export_all]). + +simple_test() -> + Sqids = new(#{ + alphabet => <<"0123456789abcdef">> + }), + Numbers = [1, 2, 3], + Id = <<"489158">>, + ?assertEqual(Id, encode(Numbers, Sqids)), + ?assertEqual(Numbers, decode(Id, Sqids)), + ok. + +short_alphabet_test() -> + Sqids = new(#{ + alphabet => <<"abc">> + }), + Numbers = [1, 2, 3], + ?assertEqual(Numbers, decode(encode(Numbers, Sqids), Sqids)), + ok. + +long_alphabet_test() -> + Sqids = new(#{ + alphabet => + << "abcdefghijklmnopqrstuvwxyz" + , "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + , "0123456789!@#$%^&*()-_+|{}" + , "[];:\\'\"/?.>,<`~" + >> + }), + Numbers = [1, 2, 3], + ?assertEqual(Numbers, decode(encode(Numbers, Sqids), Sqids)), + ok. + +multibyte_characters_test() -> + Reason = 'Alphabet cannot contain multibyte characters', + ?assertError(Reason, new(#{ + alphabet => <<"ë1092">> + })), + ok. + +repeating_alphabet_characters_test() -> + Reason = 'Alphabet must contain unique characters', + ?assertError(Reason, new(#{ + alphabet => <<"aabcdefg">> + })), + ok. + +too_short_of_an_alphabet_test() -> + Reason = 'Alphabet length must be at least 3', + ?assertError(Reason, new(#{ + alphabet => <<"ab">> + })), + ok. + diff --git a/test/sqids_blocklist_tests.erl b/test/sqids_blocklist_tests.erl new file mode 100644 index 0000000..f5b3ea1 --- /dev/null +++ b/test/sqids_blocklist_tests.erl @@ -0,0 +1,103 @@ +-module(sqids_blocklist_tests). +-include_lib("eunit/include/eunit.hrl"). +-import(sqids, [new/0, new/1, encode/2, decode/2]). + +-compile([export_all]). + +'if no custom blocklist param, use the default blocklist _test' () -> + Sqids = new(), + ?assertEqual([4572721], decode(<<"aho1e">>, Sqids)), + ?assertEqual(<<"JExTR">>, encode([4572721], Sqids)), + ok. + +'if an empty blocklist param passed, don\'t use any blocklist _test' () -> + Sqids = new(#{ + blocklist => [] + }), + ?assertEqual([4572721], decode(<<"aho1e">>, Sqids)), + ?assertEqual(<<"aho1e">>, encode([4572721], Sqids)), + ok. + +'if a non-empty blocklist param passed, use only that _test' () -> + Sqids = new(#{ + blocklist => [ + <<"ArUO">> % originally encoded [100000] + ] + }), + + % make sure we don't use the default blocklist + ?assertEqual([4572721], decode(<<"aho1e">>, Sqids)), + ?assertEqual(<<"aho1e">>, encode([4572721], Sqids)), + + % make sure we are using the passed blocklist + ?assertEqual([100000], decode(<<"ArUO">>, Sqids)), + ?assertEqual(<<"QyG4">>, encode([100000], Sqids)), + ?assertEqual([100000], decode(<<"QyG4">>, Sqids)), + ok. + +blocklist_test() -> + Sqids = new(#{ + blocklist => [ + <<"JSwXFaosAN">> % normal result of 1st encoding, let's block that word on purpose + , <<"OCjV9JK64o">> % result of 2nd encoding + , <<"rBHf">> % result of 3rd encoding is `4rBHfOiqd3`, let's block a substring + , <<"79SM">> % result of 4th encoding is `dyhgw479SM`, let's block the postfix + , <<"7tE6">> % result of 4th encoding is `7tE6jdAHLe`, let's block the prefix + ] + }), + ?assertEqual(<<"1aYeB7bRUt">>, encode([1_000_000, 2_000_000], Sqids)), + ?assertEqual([1_000_000, 2_000_000], decode(<<"1aYeB7bRUt">>, Sqids)), + ok. + +'decoding blocklist words should still work _test' () -> + Sqids = new(#{ + blocklist => [ + <<"86Rf07">> + , <<"se8ojk">> + , <<"ARsz1p">> + , <<"Q8AI49">> + , <<"5sQRZO">> + ] + }), + ?assertEqual([1, 2, 3], decode(<<"86Rf07">>, Sqids)), + ?assertEqual([1, 2, 3], decode(<<"se8ojk">>, Sqids)), + ?assertEqual([1, 2, 3], decode(<<"ARsz1p">>, Sqids)), + ?assertEqual([1, 2, 3], decode(<<"Q8AI49">>, Sqids)), + ?assertEqual([1, 2, 3], decode(<<"5sQRZO">>, Sqids)), + ok. + +'match against a short blocklist word _test' () -> + Sqids = new(#{ + blocklist => [<<"pnd">>] + }), + ?assertEqual([1000], decode(encode([1000], Sqids), Sqids)), + ok. + +'blocklist filtering in constructor _test' () -> + Sqids = new(#{ + alphabet => <<"ABCDEFGHIJKLMNOPQRSTUVWXYZ">> + % lowercase blocklist in only-uppercase alphabet + , blocklist => [<<"sxnzkl">>] + }), + Id = encode([1, 2, 3], Sqids), + Numbers = decode(Id, Sqids), + % without blocklist, would've been "SXNZKL" + ?assertEqual(<<"IBSHOZ">>, Id), + ?assertEqual([1, 2, 3], Numbers), + ok. + +'max encoding attempts _test' () -> + Alphabet = <<"abc">>, + MinLength = 3, + Blocklist = [<<"cab">>, <<"abc">>, <<"bca">>], + Sqids = new(#{ + alphabet => Alphabet + , min_length => MinLength + , blocklist => Blocklist + }), + ?assertEqual(MinLength, size(Alphabet)), + ?assertEqual(MinLength, length(Blocklist)), + Reason = 'Reached max attempts to re-generate the ID', + ?assertError(Reason, encode([0], Sqids)), + ok. + diff --git a/test/sqids_encoding_tests.erl b/test/sqids_encoding_tests.erl new file mode 100644 index 0000000..2a6a78e --- /dev/null +++ b/test/sqids_encoding_tests.erl @@ -0,0 +1,125 @@ +-module(sqids_encoding_tests). +-include_lib("eunit/include/eunit.hrl"). +-import(sqids, [new/0, new/1, encode/2, decode/2]). + +-compile([export_all]). + +simple_test() -> + Sqids = new(), + Numbers = [1, 2, 3], + Id = <<"86Rf07">>, + ?assertEqual(Id, encode(Numbers, Sqids)), + ?assertEqual(Numbers, decode(Id, Sqids)), + ok. + +different_inputs_test() -> + Sqids = new(), + LargeNum = round(1.0e+308) * round(1.0e+308), + Numbers = [0, 0, 0, 1, 2, 3, 100, 1_000, 100_000, 1_000_000, LargeNum], + assert(Sqids, Numbers), + ok. + +incremental_numbers_test() -> + Sqids = new(), + assert(Sqids, <<"bM">>, [0]), + assert(Sqids, <<"Uk">>, [1]), + assert(Sqids, <<"gb">>, [2]), + assert(Sqids, <<"Ef">>, [3]), + assert(Sqids, <<"Vq">>, [4]), + assert(Sqids, <<"uw">>, [5]), + assert(Sqids, <<"OI">>, [6]), + assert(Sqids, <<"AX">>, [7]), + assert(Sqids, <<"p6">>, [8]), + assert(Sqids, <<"nJ">>, [9]), + ok. + +'incremental numbers, same index 0 _test' () -> + Sqids = new(), + assert(Sqids, <<"SvIz">>, [0, 0]), + assert(Sqids, <<"n3qa">>, [0, 1]), + assert(Sqids, <<"tryF">>, [0, 2]), + assert(Sqids, <<"eg6q">>, [0, 3]), + assert(Sqids, <<"rSCF">>, [0, 4]), + assert(Sqids, <<"sR8x">>, [0, 5]), + assert(Sqids, <<"uY2M">>, [0, 6]), + assert(Sqids, <<"74dI">>, [0, 7]), + assert(Sqids, <<"30WX">>, [0, 8]), + assert(Sqids, <<"moxr">>, [0, 9]), + ok. + +'incremental numbers, same index 1 _test' () -> + Sqids = new(), + assert(Sqids, <<"SvIz">>, [0, 0]), + assert(Sqids, <<"nWqP">>, [1, 0]), + assert(Sqids, <<"tSyw">>, [2, 0]), + assert(Sqids, <<"eX68">>, [3, 0]), + assert(Sqids, <<"rxCY">>, [4, 0]), + assert(Sqids, <<"sV8a">>, [5, 0]), + assert(Sqids, <<"uf2K">>, [6, 0]), + assert(Sqids, <<"7Cdk">>, [7, 0]), + assert(Sqids, <<"3aWP">>, [8, 0]), + assert(Sqids, <<"m2xn">>, [9, 0]), + ok. + +multi_input_test() -> + Sqids = new(), + Numbers = lists:seq(0, 99), + ?assertEqual(0, lists:nth(1, Numbers)), + ?assertEqual(99, lists:last(Numbers)), + assert(Sqids, Numbers), + ok. + +'encoding no numbers _test' () -> + Sqids = new(), + ?assertEqual(<<"">>, encode([], Sqids)), + ok. + +'decoding empty string _test' () -> + Sqids = new(), + ?assertEqual([], decode(<<"*">>, Sqids)), + ok. + +'decoding an ID with an invalid character _test' () -> + Sqids = new(), + ?assertEqual([], decode(<<"*">>, Sqids)), + ok. + + +%%% internal functions %%% +assert(Arg1, Sqids) when is_map(Sqids) -> + assert(Sqids, Arg1); +assert(Sqids, Numbers) when is_list(Numbers) -> + ?assertEqual(Numbers, decode(encode(Numbers, Sqids), Sqids)), + ok; +assert(Sqids, Id) when is_binary(Id) -> + ?assertEqual(Id, encode(decode(Id, Sqids), Sqids)), + ok. + +assert(Arg1, Sqids, Arg2) when is_map(Sqids) -> + assert(Sqids, Arg1, Arg2); +assert(Arg1, Arg2, Sqids) when is_map(Sqids) -> + assert(Sqids, Arg1, Arg2); +assert(Sqids, Numbers, Id) when is_list(Numbers), is_binary(Id) -> + assert(Sqids, Id, Numbers); +assert(Sqids, Id, Numbers) -> + ?assertEqual(Numbers, decode(Id, Sqids)), + ?assertEqual(Id, encode(Numbers, Sqids)), + ok. + +assert_functions_test() -> + Sqids = new(), + Numbers = [1, 2, 3], + Id = <<"86Rf07">>, + % assert function args can be in any order. + assert(Sqids, Numbers), + assert(Sqids, Id), + assert(Numbers, Sqids), + assert(Id, Sqids), + assert(Sqids, Id, Numbers), + assert(Sqids, Numbers, Id), + assert(Id, Sqids, Numbers), + assert(Numbers, Sqids, Id), + assert(Id, Numbers, Sqids), + assert(Numbers, Id, Sqids), + ok. + diff --git a/test/sqids_minlength_tests.erl b/test/sqids_minlength_tests.erl new file mode 100644 index 0000000..d64bd25 --- /dev/null +++ b/test/sqids_minlength_tests.erl @@ -0,0 +1,86 @@ +-module(sqids_minlength_tests). +-include_lib("eunit/include/eunit.hrl"). +-import(sqids, [new/0, new/1, encode/2, decode/2]). + +-compile([export_all]). + +simple_test() -> + Sqids = new(#{ + min_length => size(default_options(alphabet)) + }), + Numbers = [1, 2, 3], + Id = <<"86Rf07xd4zBmiJXQG6otHEbew02c3PWsUOLZxADhCpKj7aVFv9I8RquYrNlSTM">>, + ?assertEqual(Id, encode(Numbers, Sqids)), + ?assertEqual(Numbers, decode(Id, Sqids)), + ok. + +incremental_test() -> + Numbers = [1, 2, 3], + Size = size(default_options(alphabet)), + Map = #{ + 6 => <<"86Rf07">> + , 7 => <<"86Rf07x">> + , 8 => <<"86Rf07xd">> + , 9 => <<"86Rf07xd4">> + , 10 => <<"86Rf07xd4z">> + , 11 => <<"86Rf07xd4zB">> + , 12 => <<"86Rf07xd4zBm">> + , 13 => <<"86Rf07xd4zBmi">> + , Size + 0 => <<"86Rf07xd4zBmiJXQG6otHEbew02c3PWsUOLZxADhCpKj7aVFv9I8RquYrNlSTM">> + , Size + 1 => <<"86Rf07xd4zBmiJXQG6otHEbew02c3PWsUOLZxADhCpKj7aVFv9I8RquYrNlSTMy">> + , Size + 2 => <<"86Rf07xd4zBmiJXQG6otHEbew02c3PWsUOLZxADhCpKj7aVFv9I8RquYrNlSTMyf">> + , Size + 3 => <<"86Rf07xd4zBmiJXQG6otHEbew02c3PWsUOLZxADhCpKj7aVFv9I8RquYrNlSTMyf1">> + }, + maps:foreach(fun(MinLength, Id) -> + Sqids = new(#{min_length=>MinLength}), + ?assertEqual(Id, encode(Numbers, Sqids)), + ?assertEqual(MinLength, size(encode(Numbers, Sqids))), + ?assertEqual(Numbers, decode(Id, Sqids)) + end, Map), + ok. + +incremental_numbers_test() -> + Sqids = new(#{ + min_length => size(default_options(alphabet)) + }), + Ids = #{ + <<"SvIzsqYMyQwI3GWgJAe17URxX8V924Co0DaTZLtFjHriEn5bPhcSkfmvOslpBu">> => [0, 0] + , <<"n3qafPOLKdfHpuNw3M61r95svbeJGk7aAEgYn4WlSjXURmF8IDqZBy0CT2VxQc">> => [0, 1] + , <<"tryFJbWcFMiYPg8sASm51uIV93GXTnvRzyfLleh06CpodJD42B7OraKtkQNxUZ">> => [0, 2] + , <<"eg6ql0A3XmvPoCzMlB6DraNGcWSIy5VR8iYup2Qk4tjZFKe1hbwfgHdUTsnLqE">> => [0, 3] + , <<"rSCFlp0rB2inEljaRdxKt7FkIbODSf8wYgTsZM1HL9JzN35cyoqueUvVWCm4hX">> => [0, 4] + , <<"sR8xjC8WQkOwo74PnglH1YFdTI0eaf56RGVSitzbjuZ3shNUXBrqLxEJyAmKv2">> => [0, 5] + , <<"uY2MYFqCLpgx5XQcjdtZK286AwWV7IBGEfuS9yTmbJvkzoUPeYRHr4iDs3naN0">> => [0, 6] + , <<"74dID7X28VLQhBlnGmjZrec5wTA1fqpWtK4YkaoEIM9SRNiC3gUJH0OFvsPDdy">> => [0, 7] + , <<"30WXpesPhgKiEI5RHTY7xbB1GnytJvXOl2p0AcUjdF6waZDo9Qk8VLzMuWrqCS">> => [0, 8] + , <<"moxr3HqLAK0GsTND6jowfZz3SUx7cQ8aC54Pl1RbIvFXmEJuBMYVeW9yrdOtin">> => [0, 9] + }, + maps:foreach(fun(Id, Numbers) -> + ?assertEqual(Id, encode(Numbers, Sqids)), + ?assertEqual(Numbers, decode(Id, Sqids)) + end, Ids), + ok. + +min_lengths_test() -> + LargeNum = round(1.0e+308) * round(1.0e+308), + MinLengthList = [0, 1, 5, 10, size(default_options(alphabet))], + NumbersList = [ + [0] + , [0, 0, 0, 0, 0] + , [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + , [100, 200, 300] + , [1_000, 2_000, 3_000] + , [1_000_000] + , [LargeNum] + ], + lists:foreach(fun(MinLength)->lists:foreach(fun(Numbers)-> + Sqids = new(#{min_length=>MinLength}), + Id = encode(Numbers, Sqids), + ?assert(size(Id) >= MinLength), + ?assertEqual(Numbers, decode(Id, Sqids)) + end, NumbersList)end, MinLengthList), + ok. + +default_options(Key) -> + maps:get(Key, sqids:default_options()). +