Skip to content

Commit ec50a88

Browse files
committed
Validate normalized tokenizer identifiers
Ensure identifiers that require normalization are checked again after normalization so decomposed source cannot normalize into unsupported codepoints. Closes #15419.
1 parent 311f6fb commit ec50a88

3 files changed

Lines changed: 86 additions & 30 deletions

File tree

lib/elixir/test/elixir/kernel/parser_test.exs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1359,6 +1359,14 @@ defmodule Kernel.ParserTest do
13591359
["nofile:1:4:", ~s/unexpected token: "#{"\u01DC"}" (column 4, code point U+01DC)/],
13601360
~c":fooǜ"
13611361
)
1362+
1363+
assert_syntax_error(
1364+
[
1365+
"nofile:1:7:",
1366+
~s/unexpected token: "ǜ" (column 7, code point U+01DC)/
1367+
],
1368+
~c":cōng_lǜ_green"
1369+
)
13621370
end
13631371

13641372
test "keyword missing space" do

lib/elixir/test/elixir/kernel/string_tokenizer_test.exs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,18 @@ defmodule Kernel.StringTokenizerTest do
4343
assert {:error, _} = Code.string_to_quoted(":@123")
4444
end
4545

46+
test "rejects identifiers that normalize to unsupported codepoints" do
47+
decomposed_u_with_diaeresis_and_grave = [?u, 0x0308, 0x0300]
48+
49+
assert String.Tokenizer.tokenize([0x01DC]) == {:error, :empty}
50+
51+
assert String.Tokenizer.tokenize(decomposed_u_with_diaeresis_and_grave) ==
52+
{:error, {:unexpected_token, decomposed_u_with_diaeresis_and_grave}}
53+
54+
assert String.Tokenizer.tokenize(~c"fooǜlul") ==
55+
{:error, {:unexpected_token, ~c"fooǜ"}}
56+
end
57+
4658
test "tokenizes keywords" do
4759
assert Code.string_to_quoted!("[_12: 0]") == [_12: 0]
4860
assert Code.string_to_quoted!("[ola: 0]") == [ola: 0]

lib/elixir/unicode/tokenizer.ex

Lines changed: 66 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -473,50 +473,86 @@ defmodule String.Tokenizer do
473473
original_acc = :lists.reverse(original_acc)
474474
acc = :unicode.characters_to_nfc_list(original_acc)
475475

476-
special =
476+
{different?, special} =
477477
if original_acc == acc do
478-
special
478+
{false, special}
479479
else
480-
[:nfkc | List.delete(special, :nfkc)]
480+
{true, [:nfkc | List.delete(special, :nfkc)]}
481481
end
482482

483-
if scriptset != @bottom or chunks_single?(acc) do
484-
{kind, acc, rest, length, false, special}
485-
else
486-
breakdown =
487-
for codepoint <- acc do
488-
scriptsets =
489-
case codepoint_to_scriptset(codepoint) do
490-
@top ->
491-
""
483+
cond do
484+
invalid_prefix = different? && invalid_normalized(original_acc, acc, kind) ->
485+
{:error, {:unexpected_token, invalid_prefix}}
492486

493-
scriptset ->
494-
scriptset
495-
|> ScriptSet.to_indexes()
496-
|> Enum.map(&Map.fetch!(@indexed_scriptsets, &1))
497-
|> then(&(" {" <> Enum.join(&1, ",") <> "}"))
498-
end
487+
scriptset != @bottom or chunks_single?(acc) ->
488+
{kind, acc, rest, length, false, special}
499489

500-
hex = :io_lib.format(~c"~4.16.0B", [codepoint])
501-
" \\u#{hex} #{[codepoint]}#{scriptsets}\n"
502-
end
490+
true ->
491+
breakdown =
492+
for codepoint <- acc do
493+
scriptsets =
494+
case codepoint_to_scriptset(codepoint) do
495+
@top ->
496+
""
497+
498+
scriptset ->
499+
scriptset
500+
|> ScriptSet.to_indexes()
501+
|> Enum.map(&Map.fetch!(@indexed_scriptsets, &1))
502+
|> then(&(" {" <> Enum.join(&1, ",") <> "}"))
503+
end
504+
505+
hex = :io_lib.format(~c"~4.16.0B", [codepoint])
506+
" \\u#{hex} #{[codepoint]}#{scriptsets}\n"
507+
end
503508

504-
prefix = ~c"invalid mixed-script identifier found: "
509+
prefix = ~c"invalid mixed-script identifier found: "
505510

506-
suffix = ~c"""
511+
suffix = ~c"""
507512
508513
509-
Mixed-script identifiers are not supported for security reasons. \
510-
'#{acc}' is made of the following scripts:\n
511-
#{breakdown}
512-
Characters in identifiers from different scripts must be separated \
513-
by underscore (_).
514-
"""
514+
Mixed-script identifiers are not supported for security reasons. \
515+
'#{acc}' is made of the following scripts:\n
516+
#{breakdown}
517+
Characters in identifiers from different scripts must be separated \
518+
by underscore (_).
519+
"""
515520

516-
{:error, {:mixed_script, acc, {prefix, suffix}}}
521+
{:error, {:mixed_script, acc, {prefix, suffix}}}
517522
end
518523
end
519524

525+
defp invalid_normalized(original_acc, acc, kind) do
526+
case tokenize(acc) do
527+
{^kind, _acc, [], _length, _ascii?, _special} ->
528+
false
529+
530+
{:error, {:unexpected_token, invalid_prefix}} ->
531+
invalid_original_prefix(original_acc, invalid_prefix)
532+
533+
_other ->
534+
invalid_original_prefix(original_acc, acc)
535+
end
536+
end
537+
538+
defp invalid_original_prefix(original_acc, invalid_normalized_prefix) do
539+
invalid_original_prefix(original_acc, invalid_normalized_prefix, [])
540+
end
541+
542+
defp invalid_original_prefix([head | tail], invalid_normalized_prefix, prefix) do
543+
prefix = [head | prefix]
544+
545+
if :unicode.characters_to_nfc_list(:lists.reverse(prefix)) == invalid_normalized_prefix do
546+
:lists.reverse(prefix)
547+
else
548+
invalid_original_prefix(tail, invalid_normalized_prefix, prefix)
549+
end
550+
end
551+
552+
defp invalid_original_prefix([], _invalid_normalized_prefix, prefix) do
553+
:lists.reverse(prefix)
554+
end
555+
520556
# Support script mixing via chunked identifiers (UTS 55-5's strong recommends).
521557
# Each chunk in an ident like foo_bar_baz should pass checks.
522558
defp chunks_single?(acc),

0 commit comments

Comments
 (0)