Current section
Files
Jump to
Current section
Files
src/cuttlefish_datatypes.erl
%% -------------------------------------------------------------------
%%
%% cuttlefish_datatypes: handles datatypes in cuttlefish schemas
%%
%% Copyright (c) 2013 Basho Technologies, Inc. All Rights Reserved.
%%
%% This file is provided to you under the Apache License,
%% Version 2.0 (the "License"); you may not use this file
%% except in compliance with the License. You may obtain
%% a copy of the License at
%%
%% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing,
%% software distributed under the License is distributed on an
%% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
%% KIND, either express or implied. See the License for the
%% specific language governing permissions and limitations
%% under the License.
%%
%% -------------------------------------------------------------------
-module(cuttlefish_datatypes).
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
-endif.
-type datatype() :: integer |
string |
atom |
boolean |
file |
directory |
flag |
{flag, atom(), atom()} |
{flag, {atom(), term()}, {atom(), term()}} |
{enum, [atom()]} |
ip |
fqdn |
domain_socket |
{duration, cuttlefish_duration:time_unit() } |
{duration, cuttlefish_duration:time_unit(), [numeric_constraint()] | numeric_shortcut()} |
bytesize |
{bytesize, [numeric_constraint()] | numeric_shortcut()} |
{percent, integer} |
{percent, float} |
percent |
port |
byte |
{integer, [numeric_constraint()] | numeric_shortcut()} |
{float, [numeric_constraint()] | numeric_shortcut()} |
float |
tagged_string |
regex |
uri |
{uri, [atom()]} |
{list, datatype()}.
-type numeric_constraint() :: {min, number()}
| {max, number()}
| {gt, number()}
| {lt, number()}
| numeric_shortcut()
| allow_infinity
| {validator, validator_fun()}
| {validator, string()}.
-type numeric_shortcut() :: non_negative | positive.
-type validator_fun() :: fun((term()) -> boolean()).
-type extended() :: { integer, integer() } |
{ string, string() } |
{ binary, binary() } |
{ file, file:filename() } |
{ directory, file:filename() } |
{ atom, atom() } |
{ ip, { string(), integer() } } |
{ domain_socket, {string(), integer()} } |
{ {duration, cuttlefish_duration:time_unit() }, string() } |
{ bytesize, string() } |
{ {percent, integer}, integer() } |
{ {percent, float}, float() } |
{ float, float() } |
{ tagged_string, string() } |
{ tagged_binary, string() }.
-type datatype_list() :: [ datatype() | extended() ].
-export_type([datatype/0, extended/0, datatype_list/0]).
-export([
is_supported/1,
is_extended/1,
is_valid_list/1,
from_string/2,
to_string/2,
extended_from/1,
constraint_validator_refs/1,
resolve_constraint_validators/2
]).
-spec is_supported(any()) -> boolean().
is_supported(integer) -> true;
is_supported(string) -> true;
is_supported(binary) -> true;
is_supported(file) -> true;
is_supported(directory) -> true;
is_supported(flag) -> true;
is_supported({flag, On, Off}) when is_atom(On), is_atom(Off) -> true;
is_supported({flag, {On, _}, {Off, _}}) when is_atom(On), is_atom(Off) -> true;
is_supported(atom) -> true;
is_supported(boolean) -> true;
is_supported({enum, E}) when is_list(E) -> true;
is_supported(ip) -> true;
is_supported(fqdn) -> true;
is_supported(domain_socket) -> true;
is_supported({duration, f}) -> true;
is_supported({duration, w}) -> true;
is_supported({duration, d}) -> true;
is_supported({duration, h}) -> true;
is_supported({duration, m}) -> true;
is_supported({duration, s}) -> true;
is_supported({duration, ms}) -> true;
is_supported(bytesize) -> true;
is_supported({bytesize, Constraints}) ->
valid_constraints(Constraints, integer);
is_supported({duration, Unit, Constraints})
when Unit =:= f; Unit =:= w; Unit =:= d; Unit =:= h;
Unit =:= m; Unit =:= s; Unit =:= ms ->
valid_constraints(Constraints, integer);
is_supported({percent, integer}) -> true;
is_supported({percent, float}) -> true;
is_supported(percent) -> true;
is_supported(port) -> true;
is_supported(byte) -> true;
is_supported({integer, Constraints}) ->
valid_constraints(Constraints, integer);
is_supported({float, Constraints}) ->
valid_constraints(Constraints, float);
is_supported(float) -> true;
is_supported(tagged_string) -> true;
is_supported(tagged_binary) -> true;
is_supported(regex) -> true;
is_supported(uri) -> true;
is_supported({uri, Schemes}) when is_list(Schemes), Schemes =/= [] ->
lists:all(fun is_atom/1, Schemes);
is_supported({list, {list, _}}) ->
% lists of lists are not supported
false;
is_supported({list, ListDatatype}) ->
is_supported(ListDatatype);
is_supported(_) -> false.
-spec is_extended(any()) -> boolean().
is_extended({integer, I}) when is_integer(I) -> true;
is_extended({string, S}) when is_list(S) -> true;
is_extended({binary, B}) when is_list(B) -> true;
is_extended({tagged_string, S}) when is_list(S) -> true;
is_extended({tagged_binary, B}) when is_list(B) -> true;
is_extended({atom, A}) when is_atom(A) -> true;
is_extended({file, F}) when is_list(F) -> true;
is_extended({directory, D}) when is_list(D) -> true;
is_extended({ip, {IP, Port}}) when is_list(IP) andalso is_integer(Port) -> true;
is_extended({ip, StringIP}) when is_list(StringIP) -> true;
is_extended({fqdn, {FQDN, Port}}) when is_list(FQDN) andalso is_integer(Port) -> true;
is_extended({fqdn, StringFQDN}) when is_list(StringFQDN) -> true;
is_extended({domain_socket, {local, UDS, Port}}) when is_list(UDS) andalso is_integer(Port) -> true;
is_extended({domain_socket, StringUDS}) when is_list(StringUDS) -> true;
is_extended({{duration, f}, D}) when is_list(D) -> true;
is_extended({{duration, w}, D}) when is_list(D) -> true;
is_extended({{duration, d}, D}) when is_list(D) -> true;
is_extended({{duration, g}, D}) when is_list(D) -> true;
is_extended({{duration, m}, D}) when is_list(D) -> true;
is_extended({{duration, s}, D}) when is_list(D) -> true;
is_extended({{duration, ms}, D}) when is_list(D) -> true;
is_extended({bytesize, B}) when is_list(B) -> true;
is_extended({{percent, integer}, _Int}) -> true;
is_extended({{percent, float}, _Float}) -> true;
is_extended({float, F}) when is_float(F) -> true;
is_extended(_) -> false.
-spec extended_from(extended()) -> datatype().
extended_from({integer, _}) -> integer;
extended_from({string, _}) -> string;
extended_from({binary, _}) -> binary;
extended_from({tagged_string, _}) -> tagged_string;
extended_from({tagged_binary, _}) -> tagged_binary;
extended_from({atom, _}) -> atom;
extended_from({file, _}) -> file;
extended_from({directory, _}) -> directory;
extended_from({ip, _}) -> ip;
extended_from({fqdn, _}) -> fqdn;
extended_from({domain_socket, _}) -> domain_socket;
extended_from({{duration, Unit}, _}) -> {duration, Unit};
extended_from({bytesize, _}) -> bytesize;
extended_from({{percent, integer}, _}) -> {percent, integer};
extended_from({{percent, float}, _}) -> {percent, float};
extended_from({float, _}) -> float;
extended_from(Other) ->
case is_supported(Other) of
true ->
Other;
_ -> error
end.
-spec is_valid_list(any()) -> boolean().
is_valid_list(NotList) when not is_list(NotList) ->
false;
is_valid_list([]) -> false;
is_valid_list(List) ->
lists:all(fun(X) ->
is_supported(X)
orelse
is_extended(X)
end, List).
-spec to_string(term(), datatype()) -> string() | cuttlefish_error:error().
to_string(Atom, atom) when is_list(Atom) -> Atom;
to_string(Atom, atom) when is_atom(Atom) -> atom_to_list(Atom);
to_string(true, boolean) -> "true";
to_string(false, boolean) -> "false";
to_string("true", boolean) -> "true";
to_string("false", boolean) -> "false";
to_string(Integer, integer) when is_integer(Integer) -> integer_to_list(Integer);
to_string(Integer, integer) when is_list(Integer) -> Integer;
to_string(Value, port) -> to_string(Value, integer);
to_string(Value, byte) -> to_string(Value, integer);
to_string(Value, percent) -> to_string(Value, {percent, integer});
%% The `allow_infinity' shortcut leaves the atom `infinity' unchanged
%% on the conversion path; here the printable form is the literal
%% characters. These clauses must precede the generic constraint
%% clauses below — those would match first and recurse into the
%% integer/float clauses, which reject the atom.
to_string(infinity, {integer, _}) -> "infinity";
to_string(infinity, {float, _}) -> "infinity";
to_string(infinity, {bytesize, _}) -> "infinity";
to_string(infinity, {duration, _, _}) -> "infinity";
to_string(Value, {integer, Constraints}) when is_list(Constraints); is_atom(Constraints) ->
to_string(Value, integer);
to_string(Value, {float, Constraints}) when is_list(Constraints); is_atom(Constraints) ->
to_string(Value, float);
to_string(Value, {bytesize, Constraints}) when is_list(Constraints); is_atom(Constraints) ->
to_string(Value, bytesize);
to_string(Value, {duration, Unit, Constraints}) when is_list(Constraints); is_atom(Constraints) ->
to_string(Value, {duration, Unit});
to_string({IP, Port}, ip) when is_list(IP), is_integer(Port) -> IP ++ ":" ++ integer_to_list(Port);
to_string(IPString, ip) when is_list(IPString) -> IPString;
to_string({FQDN, Port}, fqdn) when is_list(FQDN), is_integer(Port) -> FQDN ++ ":" ++ integer_to_list(Port);
to_string(FQDNString, fqdn) when is_list(FQDNString) -> FQDNString;
to_string({local, UDS, Port}, domain_socket) when is_list(UDS), is_integer(Port) ->
"local:" ++ UDS ++ ":" ++ integer_to_list(Port);
to_string(UDSString, domain_socket) when is_list(UDSString) -> UDSString;
to_string(Enum, {enum, _}) when is_list(Enum) -> Enum;
to_string(Enum, {enum, _}) when is_atom(Enum) -> atom_to_list(Enum);
to_string(Duration, {duration, _}) when is_list(Duration) -> Duration;
to_string(Duration, {duration, Unit}) when is_integer(Duration) -> cuttlefish_duration:to_string(Duration, Unit);
to_string(Bytesize, bytesize) when is_list(Bytesize) -> Bytesize;
to_string(Bytesize, bytesize) when is_integer(Bytesize) -> cuttlefish_bytesize:to_string(Bytesize);
to_string(String, string) when is_list(String) -> String;
to_string(Bin, binary) when is_list(Bin) -> Bin;
to_string(Bin, binary) when is_binary(Bin) -> binary_to_list(Bin);
to_string({Tag, String}, tagged_string) when is_list(Tag), is_list(String) -> Tag ++ ":" ++ String;
to_string({Tag, String}, tagged_binary) when is_list(Tag), is_list(String) -> Tag ++ ":" ++ String;
to_string({Tag, Bin}, tagged_binary) when is_list(Tag), is_binary(Bin) -> Tag ++ ":" ++ binary_to_list(Bin);
to_string(Regex, regex) when is_list(Regex) -> Regex;
to_string(URI, uri) when is_list(URI) -> URI;
to_string(URI, {uri, _}) when is_list(URI) -> URI;
to_string(File, file) when is_list(File) -> File;
to_string(Directory, directory) when is_list(Directory) -> Directory;
to_string(Flag, flag) when is_atom(Flag) -> cuttlefish_flag:to_string(Flag, flag);
to_string(Flag, flag) when is_list(Flag) -> cuttlefish_flag:to_string(Flag, flag);
to_string(Flag, {flag, _, _}=Type) when is_atom(Flag) -> cuttlefish_flag:to_string(Flag, Type);
to_string(Flag, {flag, _, _}=Type) when is_list(Flag) -> cuttlefish_flag:to_string(Flag, Type);
to_string(Percent, {percent, integer}) when is_integer(Percent) ->
integer_to_list(Percent) ++ "%";
to_string(Percent, {percent, integer}) when is_list(Percent) ->
Percent;
to_string(Percent, {percent, float}) when is_float(Percent) ->
P = list_to_float(float_to_list(Percent * 100, [{decimals, 6}, compact])),
integer_to_list(cuttlefish_util:ceiling(P)) ++ "%";
to_string(Percent, {percent, float}) when is_list(Percent) ->
Percent;
to_string(Float, float) when is_float(Float) ->
float_to_list(Float, [{decimals, 6}, compact]);
to_string(Float, float) when is_list(Float) -> Float;
%% The Pokemon Clause: Gotta Catch 'em all!
to_string(Value, MaybeExtendedDatatype) ->
case is_extended(MaybeExtendedDatatype) of
true ->
to_string(Value, extended_from(MaybeExtendedDatatype));
_ ->
{error, {type, {Value, MaybeExtendedDatatype}}}
end.
-define(TAGGED_VALUE_PREFIX_REGEX, <<"^[a-zA-Z0-9_]+\:">>).
-define(DEFAULT_URI_SCHEMES, [http, https]).
%% Match-step backtracking budget used when probing user patterns.
-define(MATCH_STEP_BUDGET, 1000).
%% Inputs used to probe user patterns for excessive backtracking.
-define(REGEX_PROBES, [
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"abababababababababababababababababababab!",
"1111111111111111111111111111111111111111!",
"abcdefghijklmnopqrstuvwxyz0123456789ABCD!",
"aaaa.aaaa.aaaa.aaaa.aaaaaaaaaaaaaaaaaaaa!"
]).
-spec from_string(term(), datatype()) -> term() | cuttlefish_error:error().
from_string(Atom, atom) when is_atom(Atom) -> Atom;
from_string(String, atom) when is_list(String) -> list_to_atom(String);
from_string(true, boolean) -> true;
from_string(false, boolean) -> false;
from_string("true", boolean) -> true;
from_string("false", boolean) -> false;
from_string(Value, boolean) when is_list(Value); is_atom(Value) ->
{error, {conversion, {Value, boolean}}};
from_string(String, binary) when is_list(String) -> list_to_binary(String);
from_string(Value, {enum, Enum}) ->
cuttlefish_enum:parse(Value, {enum, Enum});
from_string(Integer, integer) when is_integer(Integer) -> Integer;
from_string(String, integer) when is_list(String) ->
try list_to_integer(String) of
X -> X
catch
_:_ -> {error, {conversion, {String, integer}}}
end;
from_string(Value, port) ->
from_string(Value, {integer, [{min, 0}, {max, 65535}]});
from_string(Value, byte) ->
from_string(Value, {integer, [{min, 0}, {max, 255}]});
from_string(Value, percent) ->
from_string(Value, {percent, integer});
from_string(Value, {integer, Constraints}) ->
case maybe_infinity(Value, Constraints) of
{ok, Inf} -> Inf;
not_applicable ->
apply_numeric_constraints(from_string(Value, integer),
Constraints, integer)
end;
from_string(Value, {float, Constraints}) ->
case maybe_infinity(Value, Constraints) of
{ok, Inf} -> Inf;
not_applicable ->
apply_numeric_constraints(from_string(Value, float),
Constraints, float)
end;
from_string({IP, Port}, ip) when is_list(IP), is_integer(Port) -> {IP, Port};
from_string(String, ip) when is_list(String) ->
from_string_to_ip(String, lists:split(string:rchr(String, $:), String));
from_string({FQDN, Port}, fqdn) when is_list(FQDN), is_integer(Port) -> {FQDN, Port};
from_string(String, fqdn) when is_list(String) ->
from_string_to_fqdn(String, lists:split(string:rchr(String, $:), String));
from_string(String, tagged_string) when is_list(String) ->
Bin = list_to_binary(String),
{ok, AlphanumericPrefixRE} = re:compile(?TAGGED_VALUE_PREFIX_REGEX),
case re:run(Bin, AlphanumericPrefixRE) of
{match, _} ->
%% note: unlike lists:split/2, this splits at the first colon occurrence,
%% which is what we want here
case binary:split(Bin, <<":">>) of
[<<>>, _Val] ->
{error, {conversion, {String, "tagged string"}}};
[_Prefix, <<>>] ->
{error, {conversion, {String, "tagged string"}}};
[Prefix, Val] ->
from_string_to_tagged_string(String, {Prefix, Val});
_ ->
{error, {conversion, {String, "tagged string"}}}
end;
_ ->
{error, {conversion, {String, "tagged string"}}}
end;
from_string(String, tagged_binary) when is_list(String) ->
Bin = list_to_binary(String),
{ok, AlphanumericPrefixRE} = re:compile(?TAGGED_VALUE_PREFIX_REGEX),
case re:run(Bin, AlphanumericPrefixRE) of
{match, _} ->
%% note: unlike lists:split/2, this splits at the first colon occurrence,
%% which is what we want here
case binary:split(Bin, <<":">>) of
[<<>>, _Val] ->
{error, {conversion, {String, "tagged binary"}}};
[_Prefix, <<>>] ->
{error, {conversion, {String, "tagged binary"}}};
[Prefix, Val] ->
from_string_to_tagged_binary(String, {Prefix, Val});
_ ->
{error, {conversion, {String, "tagged binary"}}}
end;
_ ->
{error, {conversion, {String, "tagged binary"}}}
end;
from_string({local, UDS, Port}, domain_socket) when is_list(UDS), is_integer(Port) -> {local, UDS, Port};
from_string(String, domain_socket) when is_list(String) ->
from_string_to_uds(String, lists:split(string:rchr(String, $:), String));
from_string(Duration, {duration, _}) when is_integer(Duration) -> Duration;
from_string(Duration, {duration, Unit}) when is_list(Duration) -> cuttlefish_duration:parse(Duration, Unit);
from_string(Value, {duration, Unit, Constraints}) ->
case maybe_infinity(Value, Constraints) of
{ok, Inf} -> Inf;
not_applicable ->
apply_numeric_constraints(from_string(Value, {duration, Unit}),
Constraints, integer)
end;
from_string(Bytesize, bytesize) when is_integer(Bytesize) -> Bytesize;
from_string(Bytesize, bytesize) when is_list(Bytesize) -> cuttlefish_bytesize:parse(Bytesize);
from_string(Value, {bytesize, Constraints}) ->
case maybe_infinity(Value, Constraints) of
{ok, Inf} -> Inf;
not_applicable ->
apply_numeric_constraints(from_string(Value, bytesize),
Constraints, integer)
end;
from_string(String, string) when is_list(String) -> String;
from_string(String, regex) when is_list(String) ->
validate_regex(String);
from_string(String, uri) when is_list(String) ->
validate_uri(String, ?DEFAULT_URI_SCHEMES);
from_string(String, {uri, Schemes}) when is_list(String), is_list(Schemes) ->
validate_uri(String, Schemes);
from_string(File, file) when is_list(File) -> File;
from_string(Directory, directory) when is_list(Directory) -> Directory;
from_string(Flag, flag) when is_list(Flag) -> cuttlefish_flag:parse(Flag);
from_string(Flag, flag) when is_atom(Flag) -> cuttlefish_flag:parse(Flag);
from_string(Flag, {flag, _, _}=Type) when is_list(Flag) -> cuttlefish_flag:parse(Flag, Type);
from_string(Flag, {flag, _, _}=Type) when is_atom(Flag) -> cuttlefish_flag:parse(Flag, Type);
from_string(Percent, {percent, integer}) when is_integer(Percent), Percent >= 0, Percent =< 100 -> Percent;
from_string(Percent, {percent, integer}) when is_integer(Percent) ->
{error, {range, {{Percent, {percent, integer}}, "0 - 100%"}}};
%% This clause ends with a percent sign!
from_string(Percent, {percent, integer}) when is_list(Percent) ->
from_string(
list_to_integer(string:sub_string(Percent, 1, length(Percent) - 1)),
{percent, integer});
from_string(Percent, {percent, float}) when is_float(Percent), Percent >= 0, Percent =< 1 -> Percent;
from_string(Percent, {percent, float}) when is_float(Percent) ->
{error, {range, {{Percent, {percent, float}}, "0 - 100%"}}};
%% This clause ends with a percent sign!
from_string(Percent, {percent, float}) when is_list(Percent) ->
from_string(
list_to_integer(string:sub_string(Percent, 1, length(Percent) - 1)) / 100.0,
{percent, float});
from_string(Float, float) when is_float(Float) -> Float;
from_string(String, float) when is_list(String) ->
try list_to_float(String) of
X -> X
catch
_:_ -> {error, {conversion, {String, float}}}
end;
from_string(List, {list, DataType}) when is_list(List) ->
lists:map(fun(El) ->
from_string(string:trim(El), DataType)
end,
string:split(List, ",", all));
from_string(Thing, InvalidDatatype) ->
{error, {type, {Thing, InvalidDatatype}}}.
%%% Utility functions for IP conversion
port_to_integer(Str) ->
try
list_to_integer(Str)
of
X when X >= 0 ->
X;
%% Negative ports are nonsensical
_X ->
undefined
catch
_:_ ->
undefined
end.
ip_conversions(String, _IPStr, {error, einval}, _Port) ->
{error, {conversion, {String, 'IP'}}};
ip_conversions(String, _IPStr, _IP, undefined) ->
{error, {conversion, {String, 'IP'}}};
ip_conversions(_String, IPStr, {ok, _}, Port) ->
{IPStr, Port}.
fqdn_conversions(String, _FQDNStr, nomatch, _Port) ->
{error, {conversion, {String, 'FQDN'}}};
fqdn_conversions(String, _FQDNStr, _, undefined) ->
{error, {conversion, {String, 'FQDN'}}};
fqdn_conversions(_String, FQDNStr, {match, _}, Port) ->
{FQDNStr, Port}.
uds_conversions(String, _UDSStr, nomatch) ->
{error, {conversion, {String, 'UDS'}}};
uds_conversions(_String, _UDSStr, {match, Path}) ->
%% port is always 0 for unix domain sockets
{local, Path, 0}.
validate_uds(Str) ->
case string:tokens(Str, ":") of
[Pfx, Path] when Pfx =:= "local" orelse Pfx =:= "unix" ->
{match, Path};
_ ->
nomatch
end.
validate_fqdn(Str) ->
%% inspired by https://regexr.com/3g5j0, amended to disallow [:space:]
re:run(Str, "^(?!:\/\/)(?=[^[:space:]]{1,255}$)((.{1,63}\.){1,127}(?![0-9]*$)[a-z0-9-]+\.?)$", [unicode]).
droplast(List) ->
lists:sublist(List, length(List)-1).
from_string_to_ip(String, {[], String}) ->
{error, {conversion, {String, 'IP'}}}; %% No port
from_string_to_ip(String, {IpPlusColon, PortString}) ->
%% Still need to drop last character from IP, the trailing
%% colon. Perfect use case for lists:droplast/1 but it's a recent
%% addition
IP = droplast(IpPlusColon),
ip_conversions(String, IP, inet:parse_address(IP), port_to_integer(PortString)).
from_string_to_fqdn(String, {[], String}) ->
{error, {conversion, {String, 'FQDN'}}};
from_string_to_fqdn(String, {FQDNPlusColon, PortString}) ->
FQDN = droplast(FQDNPlusColon),
fqdn_conversions(String, FQDN, validate_fqdn(FQDN), port_to_integer(PortString)).
from_string_to_tagged_string(_String, {Tag, TaggedValue}) ->
{binary_to_atom(Tag), binary_to_list(TaggedValue)}.
from_string_to_tagged_binary(_String, {Tag, TaggedValue}) ->
{binary_to_atom(Tag), TaggedValue}.
from_string_to_uds(String, {[], String}) ->
{error, {conversion, {String, 'UDS'}}};
from_string_to_uds(String, {UDSPlusColon, PortString}) ->
%% Drop the trailing colon
UDS = droplast(UDSPlusColon),
%% In most API functions where you can use this address family
%% the port number must be 0.
%% See: https://www.erlang.org/doc/man/inet.html#type-local_address
case port_to_integer(PortString) of
0 ->
uds_conversions(String, UDS, validate_uds(UDS));
_OtherPort ->
{error, {conversion, {String, 'UDS'}}}
end.
validate_regex("") ->
{error, {regex_empty, ""}};
validate_regex(String) when is_list(String) ->
case re:compile(String) of
{error, {Reason, Pos}} ->
{error, {regex_invalid_syntax, {String, Reason, Pos}}};
{ok, MP} ->
%% `report_errors' is required: without it, PCRE silently turns
%% a match_limit exhaustion into `nomatch'.
Opts = [{match_limit, ?MATCH_STEP_BUDGET},
{match_limit_recursion, ?MATCH_STEP_BUDGET},
report_errors],
case probe_backtracking(String, MP, Opts) of
ok -> String;
over_budget -> {error, {regex_excessive_backtracking, String}}
end
end.
probe_backtracking(String, MP, Opts) ->
case probe_all(MP, ?REGEX_PROBES, Opts) of
over_budget -> over_budget;
ok -> probe_anchored(String, Opts)
end.
probe_anchored(String, Opts) ->
case re:compile("^(?:" ++ String ++ ")$") of
%% Rare patterns (e.g. PCRE verbs that don't compose with grouping)
%% can fail to compile when wrapped. Fall back to the plain probe.
{error, _} ->
ok;
{ok, Anchored} ->
probe_all(Anchored, ?REGEX_PROBES, Opts)
end.
probe_all(_MP, [], _Opts) -> ok;
probe_all(MP, [Probe | Rest], Opts) ->
case re:run(Probe, MP, Opts) of
{error, match_limit} -> over_budget;
{error, match_limit_recursion} -> over_budget;
_ -> probe_all(MP, Rest, Opts)
end.
validate_uri(String, Schemes) ->
case validate_uri_schemes(Schemes) of
ok -> do_validate_uri(string:trim(String), Schemes);
Error -> Error
end.
do_validate_uri("", _Schemes) ->
{error, {uri_empty, ""}};
do_validate_uri(String, Schemes) ->
case uri_string:parse(String) of
{error, _Reason, _Term} ->
{error, {uri_malformed, String}};
Parsed when is_map(Parsed) ->
check_scheme_and_host(String, Parsed, Schemes)
end.
check_scheme_and_host(String, Parsed, Schemes) ->
case maps:find(scheme, Parsed) of
error ->
{error, {uri_no_scheme, String}};
{ok, SchemeStr} ->
Lower = string:lowercase(SchemeStr),
Allowed = [atom_to_list(S) || S <- Schemes],
case lists:member(Lower, Allowed) of
false ->
{error, {uri_bad_scheme, {String, Lower, Schemes}}};
true ->
check_host(String, Parsed)
end
end.
check_host(String, Parsed) ->
case maps:get(host, Parsed, "") of
"" -> {error, {uri_no_host, String}};
_ -> String
end.
validate_uri_schemes([]) ->
{error, {uri_schemes_empty, []}};
validate_uri_schemes(Schemes) when is_list(Schemes) ->
case lists:all(fun is_atom/1, Schemes) of
true -> ok;
_ -> {error, {uri_schemes_invalid, Schemes}}
end.
valid_constraints(non_negative, _BaseType) -> true;
valid_constraints(positive, _BaseType) -> true;
valid_constraints(allow_infinity, _BaseType) -> true;
valid_constraints(Constraints, BaseType) when is_list(Constraints) ->
lists:all(fun(C) -> valid_constraint(C, BaseType) end, Constraints);
valid_constraints(_, _) -> false.
valid_constraint(non_negative, _) -> true;
valid_constraint(positive, _) -> true;
valid_constraint(allow_infinity, _) -> true;
valid_constraint({min, N}, T) -> is_bound(N, T);
valid_constraint({max, N}, T) -> is_bound(N, T);
valid_constraint({gt, N}, T) -> is_bound(N, T);
valid_constraint({lt, N}, T) -> is_bound(N, T);
valid_constraint({validator, Fun}, _) when is_function(Fun, 1) -> true;
valid_constraint({validator, Name}, _) when is_list(Name) -> true;
valid_constraint(_, _) -> false.
is_bound(N, integer) -> is_integer(N);
is_bound(N, float) -> is_number(N).
%% `allow_infinity' in the constraint list lets the atom `infinity'
%% bypass numeric parsing and bound checks. Conf-file values arrive
%% as the matching string; both forms are accepted. When the bypass
%% fires the remaining constraints are skipped: the atom is meant to
%% be a valid value regardless of bounds.
maybe_infinity(Value, Constraints) ->
case allows_infinity(Constraints) andalso is_infinity_value(Value) of
true -> {ok, infinity};
false -> not_applicable
end.
allows_infinity(allow_infinity) -> true;
allows_infinity(Constraints) when is_list(Constraints) ->
lists:member(allow_infinity, Constraints);
allows_infinity(_) -> false.
is_infinity_value(infinity) -> true;
is_infinity_value("infinity") -> true;
is_infinity_value(<<"infinity">>) -> true;
is_infinity_value(_) -> false.
apply_numeric_constraints({error, _} = E, _Constraints, _BaseType) ->
E;
apply_numeric_constraints(Value, Constraints, BaseType) ->
check_constraints(Value, expand_constraints(Constraints, BaseType)).
expand_constraints(non_negative, integer) -> [{min, 0}];
expand_constraints(non_negative, float) -> [{min, +0.0}];
expand_constraints(positive, integer) -> [{min, 1}];
expand_constraints(positive, float) -> [{gt, +0.0}];
%% `allow_infinity' is consumed by `maybe_infinity'; drop it here so
%% the numeric path doesn't try to evaluate it as a bound.
expand_constraints(allow_infinity, _) -> [];
expand_constraints(Constraints, BaseType) when is_list(Constraints) ->
lists:flatmap(fun(C) -> expand_constraints(C, BaseType) end, Constraints);
expand_constraints(C, _BaseType) -> [C].
check_constraints(Value, []) -> Value;
check_constraints(Value, [{validator, Fun} | Rest]) when is_function(Fun, 1) ->
case run_validator(Fun, Value) of
true -> check_constraints(Value, Rest);
false -> {error, {constraint_validator_failed, Value}}
end;
%% A string-form name here means name resolution did not run; surface
%% the orphan rather than silently treating it as a pass.
check_constraints(_Value, [{validator, Name} | _]) when is_list(Name) ->
{error, {constraint_validator_unresolved, Name}};
check_constraints(Value, [Constraint | Rest]) ->
case satisfies(Value, Constraint) of
true -> check_constraints(Value, Rest);
false -> {error, {range_violation, {Value, Constraint}}}
end.
%% Validator funs may throw; treat any exception as a failure rather
%% than letting it escape into the surrounding pipeline.
run_validator(Fun, Value) ->
try Fun(Value) of
true -> true;
_ -> false
catch
_:_ -> false
end.
satisfies(V, {min, N}) -> V >= N;
satisfies(V, {max, N}) -> V =< N;
satisfies(V, {gt, N}) -> V > N;
satisfies(V, {lt, N}) -> V < N.
%% Collects every string-form name referenced as `{validator, Name}'
%% inside any constraint list in `DT'. Returned sorted and unique so
%% schema-time pre-flight checks can run against a stable set —
%% catching a typo at schema load rather than at config generation.
-spec constraint_validator_refs(datatype() | [datatype()]) -> [string()].
constraint_validator_refs(DT) ->
lists:usort(collect_refs(DT)).
collect_refs(L) when is_list(L) ->
lists:flatmap(fun collect_refs/1, L);
collect_refs({integer, Cs}) -> collect_constraint_refs(Cs);
collect_refs({float, Cs}) -> collect_constraint_refs(Cs);
collect_refs({bytesize, Cs}) -> collect_constraint_refs(Cs);
collect_refs({duration, _Unit, Cs}) -> collect_constraint_refs(Cs);
collect_refs({list, Inner}) -> collect_refs(Inner);
collect_refs(_) -> [].
collect_constraint_refs(L) when is_list(L) ->
[N || {validator, N} <- L, is_list(N)];
collect_constraint_refs(_) -> [].
%% Walks the datatype expression and replaces each `{validator, Name}'
%% inside a constraint list with the resolved `{validator, Fun}'.
%% Names that don't resolve are left in place; the runtime constraint
%% pipeline surfaces them as `{constraint_validator_unresolved, Name}'.
%% Refs are expected to have been validated up front, so an unresolved
%% name reaching the runtime indicates a genuinely orphaned reference.
-spec resolve_constraint_validators(
datatype() | [datatype()],
fun((string()) -> {ok, validator_fun()} | not_found)) ->
datatype() | [datatype()].
resolve_constraint_validators(DT, Lookup) ->
walk_resolve(DT, Lookup).
walk_resolve(L, Lookup) when is_list(L) ->
[walk_resolve(E, Lookup) || E <- L];
walk_resolve({integer, Cs}, Lookup) ->
{integer, resolve_cs(Cs, Lookup)};
walk_resolve({float, Cs}, Lookup) ->
{float, resolve_cs(Cs, Lookup)};
walk_resolve({bytesize, Cs}, Lookup) ->
{bytesize, resolve_cs(Cs, Lookup)};
walk_resolve({duration, Unit, Cs}, Lookup) ->
{duration, Unit, resolve_cs(Cs, Lookup)};
walk_resolve({list, Inner}, Lookup) ->
{list, walk_resolve(Inner, Lookup)};
walk_resolve(Other, _Lookup) -> Other.
resolve_cs(Cs, Lookup) when is_list(Cs) ->
[resolve_c(C, Lookup) || C <- Cs];
resolve_cs(C, _Lookup) -> C.
resolve_c({validator, Name}, Lookup) when is_list(Name) ->
case Lookup(Name) of
{ok, Fun} when is_function(Fun, 1) -> {validator, Fun};
_ -> {validator, Name}
end;
resolve_c(C, _Lookup) -> C.
-ifdef(TEST).
-define(XLATE(X), lists:flatten(cuttlefish_error:xlate(X))).
to_string_atom_test() ->
?assertEqual("split_the", to_string(split_the, atom)),
?assertEqual("split_the", to_string("split_the", atom)).
to_string_integer_test() ->
?assertEqual("32", to_string(32, integer)),
?assertEqual("32", to_string("32", integer)).
to_string_ip_test() ->
?assertEqual("127.0.0.1:8098", to_string("127.0.0.1:8098", ip)),
?assertEqual("127.0.0.1:8098", to_string({"127.0.0.1", 8098}, ip)).
to_string_enum_test() ->
?assertEqual("true", to_string("true", {enum, [true, false]})),
?assertEqual("true", to_string(true, {enum, [true, false]})).
to_string_string_test() ->
?assertEqual("string", to_string("string", string)).
to_string_duration_test() ->
?assertEqual("1w", to_string("1w", {duration, s})),
?assertEqual("1w", to_string(604800000, {duration, ms})).
to_string_bytesize_test() ->
?assertEqual("1GB", to_string(1073741824, bytesize)),
?assertEqual("1GB", to_string("1GB", bytesize)).
to_string_percent_integer_test() ->
?assertEqual("10%", to_string(10, {percent, integer})),
?assertEqual("10%", to_string("10%", {percent, integer})),
ok.
to_string_percent_float_test() ->
?assertEqual("10%", to_string(0.1, {percent, float})),
?assertEqual("10%", to_string("10%", {percent, float})),
ok.
to_string_float_test() ->
?assertEqual("0.1", to_string(0.1, float)),
?assertEqual("0.1", to_string("0.1", float)),
ok.
to_string_extended_type_test() ->
?assertEqual("split_the", to_string(split_the, {atom, split_the})),
?assertEqual("split_the", to_string("split_the", {atom, split_the})),
?assertEqual("32", to_string(32, {integer, 32})),
?assertEqual("32", to_string("32", {integer, 32})),
?assertEqual("127.0.0.1:8098", to_string("127.0.0.1:8098", {ip, "127.0.0.1:8098"})),
?assertEqual("127.0.0.1:8098", to_string({"127.0.0.1", 8098}, {ip, {"127.0.0.1", 8098}})),
?assertEqual("example.com:8098", to_string("example.com:8098", {fqdn, "example.com:8098"})),
?assertEqual("example.com:8098", to_string({"example.com", 8098}, {fqdn, {"example.com", 8098}})),
?assertEqual("local:/path/test.sock:0",
to_string("local:/path/test.sock:0", {domain_socket, "local:/path/test.sock:0"})),
?assertEqual("unix:/path/test.sock:0",
to_string("unix:/path/test.sock:0", {domain_socket, "unix:/path/test.sock:0"})),
?assertEqual("local:/path/test.sock:0",
to_string({local, "/path/test.sock", 0}, {domain_socket, {local, "/path/test.sock", 0}})),
?assertEqual("local:/path/test.sock:0",
to_string({local, "/path/test.sock", 0}, {domain_socket, {local, "/path/test.sock", 0}})),
?assertEqual("string", to_string("string", {string, "string"})),
?assertEqual("1w", to_string("1w", {{duration, s}, "1w"})),
?assertEqual("1w", to_string(604800000, {{duration, ms}, "1w"})),
?assertEqual("1GB", to_string(1073741824, {bytesize, "1GB"})),
?assertEqual("1GB", to_string("1GB", {bytesize, "1GB"})),
?assertEqual("10%", to_string(10, {{percent, integer}, "10%"})),
?assertEqual("10%", to_string("10%", {{percent, integer}, "10%"})),
?assertEqual("10%", to_string(0.1, {{percent, float}, "10%"})),
?assertEqual("10%", to_string("10%", {{percent, float}, "10%"})),
?assertEqual("0.1", to_string(0.1, {float, 0.1})),
?assertEqual("0.1", to_string("0.1", {float, 0.1})),
ok.
to_string_unsupported_datatype_test() ->
?assertEqual("Tried to convert \"Something\" but invalid datatype: unsupported_datatype", ?XLATE(to_string("Something", unsupported_datatype))).
from_string_atom_test() ->
?assertEqual(split_the, from_string(split_the, atom)),
?assertEqual(split_the, from_string("split_the", atom)).
from_string_integer_test() ->
?assertEqual(32, from_string(32, integer)),
?assertEqual(32, from_string("32", integer)),
?assertEqual("\"thirty_two\" cannot be converted to a(n) integer", ?XLATE(from_string("thirty_two", integer))),
ok.
from_string_ip_test() ->
?assertEqual({"127.0.0.1", 8098}, from_string("127.0.0.1:8098", ip)),
?assertEqual(
{"2001:0db8:85a3:0042:1000:8a2e:0370:7334", 8098},
from_string("2001:0db8:85a3:0042:1000:8a2e:0370:7334:8098", ip)),
?assertEqual(
{"2001:0db8:85a3::0370:7334", 8098},
from_string("2001:0db8:85a3::0370:7334:8098", ip)),
?assertEqual(
{"::1", 1},
from_string("::1:1", ip)),
BadIPs = [
"This is not an IP:80",
"2001:0db8:85a3:0042:1000:8a2e:0370:80",
"127.0.0.1.1:80",
"127.256.0.1:80",
"127.0.0.1", %% No port
"127.0.0.1:-5",
"0:127.0.0.1:80",
"127.0.0.1:80l",
":1:1"
],
lists:foreach(fun(Bad) ->
?assertEqual({error, {conversion, {Bad, 'IP'}}},
from_string(Bad, ip))
end,
BadIPs),
ok.
from_string_fqdn_test() ->
?assertEqual({"fqdn.com", 8098}, from_string("fqdn.com:8098", fqdn)),
?assertEqual(
{"f.q.d.n.com", 8098},
from_string("f.q.d.n.com:8098", fqdn)),
?assertEqual(
{"fqdn.com.", 8098},
from_string("fqdn.com.:8098", fqdn)),
?assertEqual(
{"FqDn.com", 8098},
from_string("FqDn.com:8098", fqdn)),
?assertEqual(
{"ec2-35-160-210-253.us-west-2-.compute.amazonaws.com", 1},
from_string("ec2-35-160-210-253.us-west-2-.compute.amazonaws.com:1", fqdn)),
BadFQDNs = [
"This is not an fqdn:80",
"This.is not.an.fqdn:80",
"",
"127.0.0.1:80",
"fqdn.com", %% No port
"fqdn.com:-5",
"fqdn.com:80:81"
],
lists:foreach(fun(Bad) ->
?assertEqual({error, {conversion, {Bad, 'FQDN'}}},
from_string(Bad, fqdn))
end,
BadFQDNs),
ok.
from_string_domain_socket_test() ->
?assertEqual({local, "/tmp/test.sock", 0}, from_string("local:/tmp/test.sock:0", domain_socket)),
?assertEqual({local, "/tmp/test.sock", 0}, from_string("unix:/tmp/test.sock:0", domain_socket)),
?assertEqual({local, "test.sock", 0}, from_string("local:test.sock:0", domain_socket)),
?assertEqual({local, "/run/不亦樂乎.sock", 0}, from_string("local:/run/不亦樂乎.sock:0", domain_socket)),
BadUDSs = [
"local:/tmp/test.sock", %% No port
"local:/tmp/test.sock:80", %% Non 0 port
"/tmp/test.sock", %% No local prefix
"",
"local:/tmp/test.sock:foo", %% invalid port
"local:/tmp/test.sock:0:0", %% double port
"prefix:foo.sock:0" %% Bad prefix
],
lists:foreach(fun(Bad) ->
?assertEqual({error, {conversion, {Bad, 'UDS'}}},
from_string(Bad, domain_socket))
end,
BadUDSs),
ok.
from_string_enum_test() ->
?assertEqual("\"a\" is not a valid enum value, acceptable values are: b, c", ?XLATE(from_string(a, {enum, [b, c]}))),
?assertEqual(true, from_string("true", {enum, [true, false]})),
?assertEqual(true, from_string(true, {enum, [true, false]})).
from_string_duration_test() ->
%% more examples in the the cuttlefish_duration tests
?assertEqual(1100, from_string("1s100ms", {duration, ms})),
?assertEqual(1100, from_string(1100, {duration, ms})),
ok.
from_string_duration_secs_test() ->
%% more examples in the the cuttlefish_duration tests
%% also rounds up for smaller units
?assertEqual(2, from_string("1s100ms", {duration, s})),
?assertEqual(2, from_string(2, {duration, s})),
ok.
from_string_percent_integer_test() ->
?assertEqual(10, from_string("10%", {percent, integer})),
?assertEqual(10, from_string(10, {percent, integer})),
%% Range!
?assertEqual(0, from_string("0%", {percent, integer})),
?assertEqual(100, from_string("100%", {percent, integer})),
?assertEqual("110% can't be outside the range 0 - 100%", ?XLATE(from_string("110%", {percent, integer}))),
?assertEqual("-1% can't be outside the range 0 - 100%", ?XLATE(from_string("-1%", {percent, integer}))),
ok.
from_string_percent_float_test() ->
?assertEqual(0.10, from_string("10%", {percent, float})),
?assertEqual(0.10, from_string(0.1, {percent, float})),
%% Range!
?assertEqual(0.0, from_string("0%", {percent, float})),
?assertEqual(1.0, from_string("100%", {percent, float})),
?assertEqual("110% can't be outside the range 0 - 100%", ?XLATE(from_string("110%", {percent, float}))),
?assertEqual("-1% can't be outside the range 0 - 100%", ?XLATE(from_string("-1%", {percent, float}))),
ok.
from_string_float_test() ->
?assertEqual(0.1, from_string("0.100", float)),
?assertEqual(0.1, from_string(0.1, float)),
ok.
from_string_string_test() ->
?assertEqual("string", from_string("string", string)).
from_string_tagged_string_test() ->
?assertEqual({prefixed, "string"}, from_string("prefixed:string", tagged_string)),
?assertEqual({encrypted, "str:ng"}, from_string("encrypted:str:ng", tagged_string)),
%% the tag (prefix) is not alphanumeric, reject such values
Val1 = "a$$b12:12323",
?assertEqual({error,{conversion,{Val1, "tagged string"}}}, from_string(Val1, tagged_string)),
?assertMatch({error, {conversion, _}}, from_string(":value", tagged_string)),
?assertMatch({error, {conversion, _}}, from_string("prefixed:", tagged_string)).
from_string_tagged_binary_test() ->
?assertEqual({prefixed, <<"binary">>}, from_string("prefixed:binary", tagged_binary)),
?assertEqual({encrypted, <<"str:ng">>}, from_string("encrypted:str:ng", tagged_binary)),
%% the tag (prefix) is not alphanumeric, reject such values
Val1 = "a$$b12:12323",
?assertEqual({error,{conversion,{Val1, "tagged binary"}}}, from_string(Val1, tagged_binary)),
?assertMatch({error, {conversion, _}}, from_string(":value", tagged_binary)),
?assertMatch({error, {conversion, _}}, from_string("prefixed:", tagged_binary)).
from_string_string_list_test() ->
%% more examples in the the cuttlefish_duration tests
?assertEqual(["v1", "v2", "v3"], from_string("v1, v2,v3", {list, string})),
ok.
from_string_integer_list_test() ->
%% more examples in the the cuttlefish_duration tests
?assertEqual([1, 2, 3], from_string("1, 2,3", {list, integer})),
ok.
from_string_atom_list_test() ->
%% more examples in the the cuttlefish_duration tests
?assertEqual([a, b, c], from_string("a, b,c", {list, atom})),
ok.
from_string_string_in_integer_list_test() ->
%% more examples in the the cuttlefish_duration tests
?assertEqual([{error, {conversion, {"a", integer}}}, 1, 2],
from_string("a, 1,2", {list, integer})),
ok.
from_string_unsupported_datatype_test() ->
?assertEqual("Tried to convert \"string\" but invalid datatype: unsupported_datatype", ?XLATE(from_string("string", unsupported_datatype))).
is_supported_test() ->
?assert(is_supported(integer)),
?assert(is_supported(string)),
?assert(is_supported(binary)),
?assert(is_supported(atom)),
?assert(is_supported(file)),
?assert(is_supported(directory)),
?assert(is_supported({enum, [one, two, three]})),
?assert(not(is_supported({enum, not_a_list}))),
?assert(is_supported(ip)),
?assert(is_supported({duration, f})),
?assert(is_supported({duration, w})),
?assert(is_supported({duration, d})),
?assert(is_supported({duration, h})),
?assert(is_supported({duration, m})),
?assert(is_supported({duration, s})),
?assert(is_supported({duration, ms})),
?assert(is_supported(bytesize)),
?assert(is_supported(domain_socket)),
?assert(is_supported(tagged_string)),
?assert(is_supported(tagged_binary)),
?assert(is_supported({list, string})),
?assert(not(is_supported({list, {list, string}}))),
?assert(not(is_supported(some_unsupported_type))),
ok.
is_extended_test() ->
?assertEqual(true, is_extended({integer, 10})),
?assertEqual(true, is_extended({integer, -10})),
?assertEqual(false, is_extended({integer, "ten"})),
?assertEqual(true, is_extended({string, "string"})),
?assertEqual(false, is_extended({string, string})),
?assertEqual(false, is_extended({string, 10})),
?assertEqual(true, is_extended({binary, "string"})),
?assertEqual(false, is_extended({binary, string})),
?assertEqual(false, is_extended({binary, 10})),
?assertEqual(true, is_extended({atom, atom})),
?assertEqual(false, is_extended({atom, "atom"})),
?assertEqual(false, is_extended({atom, 10})),
?assertEqual(true, is_extended({file, "/tmp/foo.txt"})),
?assertEqual(true, is_extended({file, ""})),
?assertEqual(false, is_extended({file, this})),
?assertEqual(true, is_extended({directory, "/tmp/foo.txt"})),
?assertEqual(true, is_extended({directory, ""})),
?assertEqual(false, is_extended({directory, this})),
?assertEqual(true, is_extended({ip, {"1.2.3.4", 1234}})),
?assertEqual(false, is_extended({ip, {1234, 1234}})),
?assertEqual(false, is_extended({ip, {"1.2.3.4", "1234"}})),
?assertEqual(true, is_extended({domain_socket, {local, "test.sock", 1234}})),
?assertEqual(false, is_extended({domain_socket, {local, "test.sock", "1234"}})),
?assertEqual(false, is_extended({domain_socket, {local, 1234, 1234}})),
?assertEqual(false, is_extended({domain_socket, {local, "foo"}})),
?assertEqual(true, is_extended({{duration, f}, "10f"})),
?assertEqual(true, is_extended({{duration, w}, "10f"})),
?assertEqual(true, is_extended({{duration, d}, "10f"})),
?assertEqual(true, is_extended({{duration, g}, "10f"})),
?assertEqual(true, is_extended({{duration, m}, "10f"})),
?assertEqual(true, is_extended({{duration, s}, "10f"})),
?assertEqual(true, is_extended({{duration, ms}, "10ms"})),
?assertEqual(true, is_extended({bytesize, "10GB"})),
?assertEqual(true, is_extended({{percent, integer}, "10%"})),
?assertEqual(true, is_extended({{percent, integer}, 10})),
?assertEqual(true, is_extended({{percent, float}, "10%"})),
?assertEqual(true, is_extended({{percent, float}, 0.1})),
?assertEqual(true, is_extended({float, 0.1})),
?assertEqual(true, is_extended({tagged_string, "tag:value"})),
?assertEqual(true, is_extended({tagged_binary, "tag:value"})),
ok.
-endif.