194 lines
6.1 KiB
Elixir
194 lines
6.1 KiB
Elixir
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: 2021 The Elixir Team
|
|
|
|
defmodule String.Tokenizer.Security do
|
|
@moduledoc false
|
|
|
|
# UTS39 security checks that operate on all tokens in a file,
|
|
# like Confusables. If we add whole-file mixed-script-confusable-characters
|
|
# checks we can add them to the list of lints here
|
|
def unicode_lint_warnings(tokens) do
|
|
for warning <- confusables(tokens),
|
|
do: format_warning(warning)
|
|
end
|
|
|
|
defp format_warning({token, reason}) do
|
|
{_, {line, col, _}, _} = token
|
|
{{line, col}, to_charlist(reason)}
|
|
end
|
|
|
|
## Confusables
|
|
|
|
defp confusables(tokens) do
|
|
{_, warnings} =
|
|
for token <- tokens, reduce: {%{}, []} do
|
|
{skeletons, warnings} ->
|
|
case check_token_for_confusability(token, skeletons) do
|
|
{:ok, skeletons} -> {skeletons, warnings}
|
|
{:warn, reason} -> {skeletons, [{token, reason} | warnings]}
|
|
end
|
|
end
|
|
|
|
warnings
|
|
end
|
|
|
|
@identifiers [
|
|
:identifier,
|
|
:op_identifier,
|
|
:kw_identifier,
|
|
:paren_identifier,
|
|
:bracket_identifier,
|
|
:alias,
|
|
:atom
|
|
]
|
|
|
|
defp check_token_for_confusability(
|
|
{kind, {_line, _column, [_ | _] = name} = info, _},
|
|
skeletons
|
|
)
|
|
when kind in @identifiers do
|
|
skeleton = confusable_skeleton(name)
|
|
|
|
case skeletons[skeleton] do
|
|
{_, _, ^name} ->
|
|
{:ok, skeletons}
|
|
|
|
{line, _, previous_name} when name != previous_name ->
|
|
{:warn,
|
|
"confusable identifier: '#{name}' looks like '#{previous_name}' on line #{line}, " <>
|
|
"but they are written using different characters" <> dir_compare(name, previous_name)}
|
|
|
|
_ ->
|
|
{:ok, Map.put(skeletons, skeleton, info)}
|
|
end
|
|
end
|
|
|
|
defp check_token_for_confusability(_token, skeletons), do: {:ok, skeletons}
|
|
|
|
# AAAA ; BBBB CCCC DDDDD ;
|
|
# ^ char ^ prototypical char or sequence of chars it can be confused with
|
|
confusables_path = "confusables.txt"
|
|
|
|
lines =
|
|
Path.join(__DIR__, confusables_path)
|
|
|> File.read!()
|
|
|> String.split(["\r\n", "\n"], trim: true)
|
|
|
|
regex = ~r/^((?:[0-9A-F]+ )+);\t((?:[0-9A-F]+ )+);/u
|
|
matches = Enum.map(lines, &Regex.run(regex, &1, capture: :all_but_first))
|
|
|
|
confusable_prototype_lookup =
|
|
for [confusable_str, prototype_str] <- matches, reduce: %{} do
|
|
acc ->
|
|
confusable = String.to_integer(String.trim(confusable_str), 16)
|
|
|
|
if Map.has_key?(acc, confusable) or
|
|
confusable in ?A..?Z or confusable in ?a..?z or confusable in ?0..?9 do
|
|
acc
|
|
else
|
|
prototype =
|
|
prototype_str
|
|
|> String.split(" ", trim: true)
|
|
|> Enum.map(&String.to_integer(&1, 16))
|
|
|
|
Map.put(acc, confusable, prototype)
|
|
end
|
|
end
|
|
|
|
for {confusable, prototype} <- confusable_prototype_lookup do
|
|
defp confusable_prototype(unquote(confusable)) do
|
|
unquote(prototype)
|
|
end
|
|
end
|
|
|
|
defp confusable_prototype(other), do: <<other::utf8>>
|
|
|
|
def confusable_skeleton(s) do
|
|
# "- Convert X to NFD format, as described in [UAX15].
|
|
# - Concatenate the prototypes for each character in X according to
|
|
# the specified data, producing a string of exemplar characters.
|
|
# - Reapply NFD." (UTS 39 section 4, skeleton definition)
|
|
:unicode.characters_to_nfd_list(s)
|
|
|> bidi_skeleton()
|
|
|> :unicode.characters_to_nfd_list()
|
|
end
|
|
|
|
# Unicode 15 adds bidiSkeleton because, w/RTL codepoints, idents that
|
|
# aren't confusable LTR *are* confusable in most places human review
|
|
# occurs (editors/browsers, thanks to bidi algo, UAX9).
|
|
#
|
|
# The solution is to detect spans with reversed visual direction,
|
|
# and reverse those, so that the input we check for confusability
|
|
# matches the perceived sequence instead of the byte sequence.
|
|
#
|
|
# (we need this regardless of script mixing, because direction-neutral
|
|
# chars like _ or 0..9 can mix w/RTL chars).
|
|
def bidi_skeleton(s) do
|
|
# UTS39-28 4:
|
|
#
|
|
# Bidirectional confusability is costlier to check than
|
|
# confusability, as [unicode bidi algo] must be applied.
|
|
# [...] a fast path can be used: [...] if X has no characters
|
|
# w/bidi classes R or AL, bidiSkeleton(X) = skeleton(X)
|
|
if match?([_, _ | _], s) and any_rtl?(s) do
|
|
unbidify(s) |> Enum.map(&confusable_prototype/1)
|
|
else
|
|
Enum.map(s, &confusable_prototype/1)
|
|
end
|
|
end
|
|
|
|
defp any_rtl?(s), do: Enum.any?(s, &(:rtl == String.Tokenizer.dir(&1)))
|
|
|
|
defp dir_compare(a, b) do
|
|
"""
|
|
#{if any_rtl?(a), do: "\n\n" <> dir_breakdown(a)}
|
|
#{if any_rtl?(b), do: dir_breakdown(b)}
|
|
"""
|
|
end
|
|
|
|
defp dir_breakdown(s) do
|
|
init = "'#{s}' includes right-to-left characters:\n"
|
|
|
|
init <>
|
|
for codepoint <- s, into: "" do
|
|
hex = :io_lib.format(~c"~4.16.0B", [codepoint])
|
|
" \\u#{hex} #{[codepoint]} #{String.Tokenizer.dir(codepoint)}\n"
|
|
end
|
|
end
|
|
|
|
# make charlist match visual order by reversing spans of {rtl, neutral}
|
|
# and attaching neutral characters and weak number types according to uax9
|
|
#
|
|
# UTS39-28 4: '[...] if the strings are known not to contain explicit
|
|
# directional formatting characters[...], the algorithm can
|
|
# be drastically simplified, [...], obviating the need for
|
|
# the [...] stack of the [unicode bidi algo]'
|
|
def unbidify(chars) when is_list(chars) do
|
|
{neutrals, direction, last_part, acc} =
|
|
Enum.reduce(chars, {[], :ltr, [], []}, fn head, {neutrals, part_dir, part, acc} ->
|
|
# https://www.unicode.org/reports/tr9/#W2
|
|
case String.Tokenizer.dir(head) do
|
|
:weak_number ->
|
|
{[], part_dir, [head] ++ neutrals ++ part, acc}
|
|
|
|
:neutral ->
|
|
{[head | neutrals], part_dir, part, acc}
|
|
|
|
^part_dir ->
|
|
{[], part_dir, [head | neutrals] ++ part, acc}
|
|
|
|
:ltr when part_dir == :rtl ->
|
|
{[], :ltr, [head | neutrals], Enum.reverse(part, acc)}
|
|
|
|
:rtl when part_dir == :ltr ->
|
|
{[], :rtl, [head], neutrals ++ part ++ acc}
|
|
end
|
|
end)
|
|
|
|
case direction do
|
|
:ltr -> Enum.reverse(acc, Enum.reverse(neutrals ++ last_part))
|
|
:rtl -> Enum.reverse(acc, neutrals ++ last_part)
|
|
end
|
|
end
|
|
end
|