%% -*- coding: utf-8 -*- %%%------------------------------------------------------------------- %%% @doc A pure Erlang IDNA implementation. %%% %%% This module provides functions to encode and decode Internationalized %%% Domain Names (IDN) using the IDNA protocol as defined in %%% RFC 5891. %%% %%% == Features == %%% %%% %%% == Basic Usage == %%% ``` %%% %% Encode a domain name to ASCII (Punycode) %%% "xn--nxasmq5b.com" = idna:encode("βόλος.com"). %%% %%% %% Decode an ASCII domain name to Unicode %%% "βόλος.com" = idna:decode("xn--nxasmq5b.com"). %%% %%% %% Use UTS #46 processing %%% "xn--fa-hia.de" = idna:encode("faß.de", [uts46]). %%% ''' %%% %%% @end %%%------------------------------------------------------------------- %%% This file is part of erlang-idna released under the MIT license. %%% See the LICENSE for more information. %%%------------------------------------------------------------------- -module(idna). %% API -export([encode/1, encode/2, decode/1, decode/2]). %% Compatibility API -export([to_ascii/1, to_unicode/1, utf8_to_ascii/1, from_ascii/1]). %% Label functions -export([alabel/1, ulabel/1]). %% Validation functions -export([check_hyphen/1, check_nfc/1, check_context/1, check_initial_combiner/1, check_label_length/1]). -export([check_label/1, check_label/4]). -define(ACE_PREFIX, "xn--"). -ifdef('OTP_RELEASE'). -define(lower(C), string:lowercase(C)). -else. -define(lower(C), string:to_lower(C)). -endif. -include("idna_logger.hrl"). %%-------------------------------------------------------------------- %% Types %%-------------------------------------------------------------------- -type idna_flag() :: uts46 | {uts46, boolean()} | std3_rules | {std3_rules, boolean()} | transitional | {transitional, boolean()} | strict | {strict, boolean()}. %% IDNA processing options. %% %% -type idna_flags() :: [idna_flag()]. %% List of IDNA processing options. -type label() :: string(). %% A single label (part between dots) of a domain name. -type domain() :: string(). %% A full domain name (may contain multiple labels separated by dots). -export_type([idna_flags/0, idna_flag/0, label/0, domain/0]). %%-------------------------------------------------------------------- %% @doc Encode a domain name to ASCII using the IDNA protocol. %% %% Equivalent to `encode(Domain, [])'. %% @end %%-------------------------------------------------------------------- -spec encode(Domain) -> AsciiDomain when Domain :: domain(), AsciiDomain :: domain(). encode(Domain) -> encode(Domain, []). %%-------------------------------------------------------------------- %% @doc Encode a domain name to ASCII using the IDNA protocol with options. %% %% Converts an Internationalized Domain Name to its ASCII-compatible %% encoding (ACE) form using Punycode. %% %% == Options == %% %% %% == Examples == %% ``` %% %% Basic encoding %% "xn--nxasmq5b.com" = idna:encode("βόλος.com"). %% %% %% With UTS #46 processing %% "xn--fa-hia.de" = idna:encode("faß.de", [uts46]). %% %% %% With transitional processing (ß -> ss) %% "fass.de" = idna:encode("faß.de", [uts46, transitional]). %% ''' %% @end %%-------------------------------------------------------------------- -spec encode(Domain, Options) -> AsciiDomain when Domain :: domain(), Options :: idna_flags(), AsciiDomain :: domain(). encode(Domain0, Options) -> ok = validate_options(Options), Domain = case proplists:get_value(uts46, Options, false) of true -> STD3Rules = proplists:get_value(std3_rules, Options, false), Transitional = proplists:get_value(transitional, Options, false), uts46_remap(Domain0, STD3Rules, Transitional); false -> Domain0 end, Labels = case proplists:get_value(strict, Options, false) of false -> re:split(Domain, "[.。.。]", [{return, list}, unicode]); true -> string:tokens(Domain, ".") end, case Labels of [] -> exit(empty_domain); _ -> encode_1(Labels, []) end. %%-------------------------------------------------------------------- %% @doc Decode an ASCII domain name to Unicode using the IDNA protocol. %% %% Equivalent to `decode(Domain, [])'. %% @end %%-------------------------------------------------------------------- -spec decode(AsciiDomain) -> Domain when AsciiDomain :: domain(), Domain :: domain(). decode(Domain) -> decode(Domain, []). %%-------------------------------------------------------------------- %% @doc Decode an ASCII domain name to Unicode using the IDNA protocol with options. %% %% Converts an ASCII-compatible encoding (ACE) domain name back to its %% Unicode representation. %% %% == Options == %% Same options as {@link encode/2}. %% %% == Examples == %% ``` %% %% Basic decoding %% "βόλος.com" = idna:decode("xn--nxasmq5b.com"). %% %% %% Decode with UTS #46 processing %% "faß.de" = idna:decode("xn--fa-hia.de", [uts46]). %% ''' %% @end %%-------------------------------------------------------------------- -spec decode(AsciiDomain, Options) -> Domain when AsciiDomain :: domain(), Options :: idna_flags(), Domain :: domain(). decode(Domain0, Options) -> ok = validate_options(Options), Domain = case proplists:get_value(uts46, Options, false) of true -> STD3Rules = proplists:get_value(std3_rules, Options, false), Transitional = proplists:get_value(transitional, Options, false), uts46_remap(Domain0, STD3Rules, Transitional); false -> Domain0 end, Labels = case proplists:get_value(strict, Options, false) of false -> re:split(lowercase(Domain), "[.。.。]", [{return, list}, unicode]); true -> string:tokens(lowercase(Domain), ".") end, case Labels of [] -> exit(empty_domain); _ -> decode_1(Labels, []) end. %%-------------------------------------------------------------------- %% Compatibility API %%-------------------------------------------------------------------- %%-------------------------------------------------------------------- %% @doc Encode a domain name to ASCII (compatibility API). %% %% This function is provided for backward compatibility with older %% IDNA libraries. It is equivalent to {@link encode/1}. %% %% @deprecated Use {@link encode/1} instead. %% @end %%-------------------------------------------------------------------- -spec to_ascii(Domain) -> AsciiDomain when Domain :: domain(), AsciiDomain :: domain(). to_ascii(Domain) -> encode(Domain). %%-------------------------------------------------------------------- %% @doc Decode an ASCII domain name to Unicode (compatibility API). %% %% This function is provided for backward compatibility with older %% IDNA libraries. It is equivalent to {@link decode/1}. %% %% @deprecated Use {@link decode/1} instead. %% @end %%-------------------------------------------------------------------- -spec to_unicode(AsciiDomain) -> Domain when AsciiDomain :: domain(), Domain :: domain(). to_unicode(Domain) -> decode(Domain). %%-------------------------------------------------------------------- %% @doc Convert a UTF-8 binary domain to ASCII. %% %% Converts the UTF-8 encoded domain to a Unicode string first, %% then encodes it to ASCII. %% %% @deprecated Use {@link encode/1} with proper Unicode string instead. %% @end %%-------------------------------------------------------------------- -spec utf8_to_ascii(Utf8Domain) -> AsciiDomain when Utf8Domain :: binary() | string(), AsciiDomain :: domain(). utf8_to_ascii(Domain) -> to_ascii(idna_ucs:from_utf8(Domain)). %%-------------------------------------------------------------------- %% @doc Decode an ASCII domain name to Unicode (compatibility API). %% %% This function is provided for backward compatibility. It is %% equivalent to {@link decode/1}. %% %% @deprecated Use {@link decode/1} instead. %% @end %%-------------------------------------------------------------------- -spec from_ascii(AsciiDomain) -> Domain when AsciiDomain :: domain(), Domain :: domain(). from_ascii(Domain) -> decode(Domain). %%-------------------------------------------------------------------- %% Internal functions %%-------------------------------------------------------------------- %% @private validate_options([]) -> ok; validate_options([uts46|Rs]) -> validate_options(Rs); validate_options([{uts46, B}|Rs]) when is_boolean(B) -> validate_options(Rs); validate_options([strict|Rs]) -> validate_options(Rs); validate_options([{strict, B}|Rs]) when is_boolean(B) -> validate_options(Rs); validate_options([std3_rules|Rs]) -> validate_options(Rs); validate_options([{std3_rules, B}|Rs]) when is_boolean(B) -> validate_options(Rs); validate_options([transitional|Rs]) -> validate_options(Rs); validate_options([{transitional, B}|Rs]) when is_boolean(B) -> validate_options(Rs); validate_options([_]) -> erlang:error(badarg). %% @private encode_1([], Acc) -> lists:reverse(Acc); encode_1([Label|Labels], []) -> encode_1(Labels, lists:reverse(alabel(Label))); encode_1([Label|Labels], Acc) -> encode_1(Labels, lists:reverse(alabel(Label), [$.|Acc])). %%-------------------------------------------------------------------- %% @doc Check that a label is in Unicode Normalization Form C (NFC). %% %% Validates that the label is properly normalized according to %% RFC 5891 Section 4.2.1. %% %% Exits with `{bad_label, {nfc, Reason}}' if validation fails. %% @end %%-------------------------------------------------------------------- -spec check_nfc(Label) -> ok when Label :: label(). check_nfc(Label) -> case characters_to_nfc_list(Label) of Label -> ok; _ -> erlang:exit({bad_label, {nfc, "Label must be in Normalization Form C"}}) end. %%-------------------------------------------------------------------- %% @doc Check that a label conforms to hyphen placement rules. %% %% Validates that the label does not have hyphens in the 3rd and 4th %% positions (which would indicate an ACE prefix) and does not start %% or end with a hyphen. %% %% See RFC 5891 Section 4.2.3.1. %% %% Exits with `{bad_label, {hyphen, Reason}}' if validation fails. %% @end %%-------------------------------------------------------------------- -spec check_hyphen(Label) -> ok when Label :: label(). check_hyphen(Label) -> check_hyphen(Label, true). %% @private check_hyphen(Label, true) when length(Label) >= 3 -> case lists:nthtail(2, Label) of [$-, $-|_] -> ErrorMsg = error_msg("Label ~p has disallowed hyphens in 3rd and 4th position", [Label]), erlang:exit({bad_label, {hyphen, ErrorMsg}}); _ -> case (lists:nth(1, Label) == $-) orelse (lists:last(Label) == $-) of true -> ErrorMsg = error_msg("Label ~p must not start or end with a hyphen", [Label]), erlang:exit({bad_label, {hyphen, ErrorMsg}}); false -> ok end end; check_hyphen(Label, true) -> case (lists:nth(1, Label) == $-) orelse (lists:last(Label) == $-) of true -> ErrorMsg = error_msg("Label ~p must not start or end with a hyphen", [Label]), erlang:exit({bad_label, {hyphen, ErrorMsg}}); false -> ok end; check_hyphen(_Label, false) -> ok. %%-------------------------------------------------------------------- %% @doc Check that a label does not begin with a combining mark. %% %% Validates that the label does not start with a combining character %% as required by RFC 5891 Section 4.2.3.2. %% %% Exits with `{bad_label, {initial_combiner, Reason}}' if validation fails. %% @end %%-------------------------------------------------------------------- -spec check_initial_combiner(Label) -> ok when Label :: label(). check_initial_combiner([CP|_]) -> case idna_data:lookup(CP) of {[$M|_], _} -> erlang:exit({bad_label, {initial_combiner, "Label begins with an illegal combining character"}}); _ -> ok end. %%-------------------------------------------------------------------- %% @doc Check contextual rules for characters in a label. %% %% Validates that all characters in the label are either PVALID %% (protocol valid) or pass their contextual rules (CONTEXTJ/CONTEXTO) %% as defined in RFC 5892. %% %% Exits with `{bad_label, {context, Reason}}' if validation fails. %% @end %%-------------------------------------------------------------------- -spec check_context(Label) -> ok when Label :: label(). check_context(Label) -> check_context(Label, Label, true, 0). %% @private check_context(Label, CheckJoiners) -> check_context(Label, Label, CheckJoiners, 0). %% @private check_context([CP | Rest], Label, CheckJoiners, Pos) -> case idna_table:lookup(CP) of 'PVALID' -> check_context(Rest, Label, CheckJoiners, Pos + 1); 'CONTEXTJ' -> ok = valid_contextj(CP, Label, Pos, CheckJoiners), check_context(Rest, Label, CheckJoiners, Pos + 1); 'CONTEXTO' -> ok = valid_contexto(CP, Label, Pos, CheckJoiners), check_context(Rest, Label, CheckJoiners, Pos + 1); _Status -> ErrorMsg = error_msg("Codepoint ~p not allowed (~p) at position ~p in ~p", [CP, _Status, Pos, Label]), erlang:exit({bad_label, {context, ErrorMsg}}) end; check_context([], _, _, _) -> ok. %% @private valid_contextj(CP, Label, Pos, true) -> case idna_context:valid_contextj(CP, Label, Pos) of true -> ok; false -> ErrorMsg = error_msg("Joiner ~p not allowed at position ~p in ~p", [CP, Pos, Label]), erlang:exit({bad_label, {contextj, ErrorMsg}}) end; valid_contextj(_CP, _Label, _Pos, false) -> ok. %% @private valid_contexto(CP, Label, Pos, true) -> case idna_context:valid_contexto(CP, Label, Pos) of true -> ok; false -> ErrorMsg = error_msg("Joiner ~p not allowed at position ~p in ~p", [CP, Pos, Label]), erlang:exit({bad_label, {contexto, ErrorMsg}}) end; valid_contexto(_CP, _Label, _Pos, false) -> ok. %%-------------------------------------------------------------------- %% @doc Validate a domain label with default settings. %% %% Equivalent to `check_label(Label, true, true, true)'. %% %% Performs all IDNA validation checks: NFC normalization, hyphen rules, %% initial combiner, context rules, and BIDI rules. %% @end %%-------------------------------------------------------------------- -spec check_label(Label) -> ok when Label :: label(). check_label(Label) -> check_label(Label, true, true, true). %%-------------------------------------------------------------------- %% @doc Validate a domain label with configurable checks. %% %% Validates that a label conforms to IDNA requirements. The following %% checks can be enabled or disabled: %% %% %% %% NFC normalization and initial combiner checks are always performed. %% %% Exits with `{bad_label, {Reason, Message}}' if validation fails. %% @end %%-------------------------------------------------------------------- -spec check_label(Label, CheckHyphens, CheckJoiners, CheckBidi) -> ok when Label :: label(), CheckHyphens :: boolean(), CheckJoiners :: boolean(), CheckBidi :: boolean(). check_label(Label, CheckHyphens, CheckJoiners, CheckBidi) -> ok = check_nfc(Label), ok = check_hyphen(Label, CheckHyphens), ok = check_initial_combiner(Label), ok = check_context(Label, CheckJoiners), ok = check_bidi(Label, CheckBidi), ok. %% @private check_bidi(Label, true) -> idna_bidi:check_bidi(Label); check_bidi(_, false) -> ok. %%-------------------------------------------------------------------- %% @doc Check that a label does not exceed the maximum length. %% %% Labels in DNS are limited to 63 octets. This function validates %% that the label length does not exceed this limit. %% %% Exits with `{bad_label, {too_long, Reason}}' if validation fails. %% @end %%-------------------------------------------------------------------- -spec check_label_length(Label) -> ok when Label :: label(). check_label_length(Label) when length(Label) > 63 -> ErrorMsg = error_msg("The label ~p is too long", [Label]), erlang:exit({bad_label, {too_long, ErrorMsg}}); check_label_length(_) -> ok. %%-------------------------------------------------------------------- %% @doc Convert a label to its ASCII-compatible encoding (A-label). %% %% Takes a Unicode label and returns its Punycode-encoded form with %% the "xn--" ACE prefix. If the label is already ASCII, it is %% validated and returned as-is. %% %% == Examples == %% ``` %% "xn--nxasmq5b" = idna:alabel("βόλος"). %% "example" = idna:alabel("example"). %% ''' %% %% Exits with `{bad_label, Reason}' if the label is invalid. %% @end %%-------------------------------------------------------------------- -spec alabel(Label) -> ALabel when Label :: label(), ALabel :: label(). alabel(Label0) -> Label = case lists:all(fun(C) -> idna_ucs:is_ascii(C) end, Label0) of true -> _ = try ulabel(Label0) catch _:Error -> ErrorMsg = error_msg("The label ~p is not a valid A-label: ulabel error=~p", [Label0, Error]), erlang:exit({bad_label, {alabel, ErrorMsg}}) end, ok = check_label_length(Label0), Label0; false -> ok = check_label(Label0), ?ACE_PREFIX ++ punycode:encode(Label0) end, ok = check_label_length(Label), Label. %% @private decode_1([], Acc) -> lists:reverse(Acc); decode_1([Label|Labels], []) -> decode_1(Labels, lists:reverse(ulabel(Label))); decode_1([Label|Labels], Acc) -> decode_1(Labels, lists:reverse(ulabel(Label), [$.|Acc])). %%-------------------------------------------------------------------- %% @doc Convert a label to its Unicode form (U-label). %% %% Takes an ASCII label (potentially Punycode-encoded with "xn--" prefix) %% and returns its Unicode representation. The result is validated %% against IDNA rules. %% %% == Examples == %% ``` %% "βόλος" = idna:ulabel("xn--nxasmq5b"). %% "example" = idna:ulabel("example"). %% ''' %% %% Exits with `{bad_label, Reason}' if the label is invalid. %% @end %%-------------------------------------------------------------------- -spec ulabel(ALabel) -> Label when ALabel :: label(), Label :: label(). ulabel([]) -> []; ulabel(Label0) -> Label = case lists:all(fun(C) -> idna_ucs:is_ascii(C) end, Label0) of true -> case Label0 of [$x,$n,$-,$-|Label1] -> punycode:decode(lowercase(Label1)); _ -> lowercase(Label0) end; false -> lowercase(Label0) end, ok = check_label(Label), Label. %% Lowercase all chars in Str -spec lowercase(String::unicode:chardata()) -> unicode:chardata(). lowercase(CD) when is_list(CD) -> try lowercase_list(CD, false) catch unchanged -> CD end; lowercase(<>=Orig) -> try lowercase_bin(CP1, Rest, false) of List -> unicode:characters_to_binary(List) catch unchanged -> Orig end; lowercase(<<>>) -> <<>>. lowercase_list([CP1|[CP2|_]=Cont], _Changed) when $A =< CP1, CP1 =< $Z, CP2 < 256 -> [CP1+32|lowercase_list(Cont, true)]; lowercase_list([CP1|[CP2|_]=Cont], Changed) when CP1 < 128, CP2 < 256 -> [CP1|lowercase_list(Cont, Changed)]; lowercase_list([], true) -> []; lowercase_list([], false) -> throw(unchanged); lowercase_list(CPs0, Changed) -> case unicode_util:lowercase(CPs0) of [Char|CPs] when Char =:= hd(CPs0) -> [Char|lowercase_list(CPs, Changed)]; [Char|CPs] -> append(Char,lowercase_list(CPs, true)); [] -> lowercase_list([], Changed) end. lowercase_bin(CP1, <>, _Changed) when $A =< CP1, CP1 =< $Z, CP2 < 256 -> [CP1+32|lowercase_bin(CP2, Bin, true)]; lowercase_bin(CP1, <>, Changed) when CP1 < 128, CP2 < 256 -> [CP1|lowercase_bin(CP2, Bin, Changed)]; lowercase_bin(CP1, Bin, Changed) -> case unicode_util:lowercase([CP1|Bin]) of [CP1|CPs] -> case unicode_util:cp(CPs) of [Next|Rest] -> [CP1|lowercase_bin(Next, Rest, Changed)]; [] when Changed -> [CP1]; [] -> throw(unchanged) end; [Char|CPs] -> case unicode_util:cp(CPs) of [Next|Rest] -> [Char|lowercase_bin(Next, Rest, true)]; [] -> [Char] end end. append(Char, <<>>) when is_integer(Char) -> [Char]; append(Char, <<>>) when is_list(Char) -> Char; append(Char, Bin) when is_binary(Bin) -> [Char,Bin]; append(Char, Str) when is_integer(Char) -> [Char|Str]; append(GC, Str) when is_list(GC) -> GC ++ Str. characters_to_nfc_list(CD) -> case unicode_util:nfc(CD) of [CPs|Str] when is_list(CPs) -> CPs ++ characters_to_nfc_list(Str); [CP|Str] -> [CP|characters_to_nfc_list(Str)]; [] -> [] end. uts46_remap(Str, Std3Rules, Transitional) -> characters_to_nfc_list(uts46_remap_1(Str, Std3Rules, Transitional)). uts46_remap_1([Cp|Rs], Std3Rules, Transitional) -> Row = try idna_mapping:uts46_map(Cp) catch error:badarg -> ?LOG_ERROR("codepoint ~p not found in mapping list~n", [Cp]), erlang:exit({invalid_codepoint, Cp}) end, {Status, Replacement} = case Row of {_, _} -> Row; S -> {S, undefined} end, if (Status =:= 'V'); ((Status =:= 'D') andalso (Transitional =:= false)); ((Status =:= '3') andalso (Std3Rules =:= true) andalso (Replacement =:= undefined)) -> [Cp] ++ uts46_remap_1(Rs, Std3Rules, Transitional); (Replacement =/= undefined) andalso ( (Status =:= 'M') orelse (Status =:= '3' andalso Std3Rules =:= false) orelse (Status =:= 'D' andalso Transitional =:= true)) -> %% Recursively process replacement characters (they may have their own mappings) uts46_remap_1(Replacement ++ Rs, Std3Rules, Transitional); (Status =:= 'I') -> uts46_remap_1(Rs, Std3Rules, Transitional); true -> erlang:exit({invalid_codepoint, Cp}) end; uts46_remap_1([], _, _) -> []. error_msg(Msg, Fmt) -> lists:flatten(io_lib:format(Msg, Fmt)).