diff --git a/CHANGELOG.md b/CHANGELOG.md index d5457010..d179d5ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## v2.5.1-dev +### Bug fixes + +* Remove regexes from compiled code. Compiled regex patterns are not portable across OTP versions, so the precompiled archive could crash with `:re.import/1 is undefined` when built and run on different OTP versions + ## v2.5.0 (2026-06-28) ### Enhancements diff --git a/lib/hex/cooldown.ex b/lib/hex/cooldown.ex index ece91156..53b93d8c 100644 --- a/lib/hex/cooldown.ex +++ b/lib/hex/cooldown.ex @@ -70,15 +70,16 @@ defmodule Hex.Cooldown do @spec duration_to_seconds(String.t()) :: {:ok, non_neg_integer()} | :error def duration_to_seconds("0"), do: {:ok, 0} - def duration_to_seconds(string) when is_binary(string) do - with [_, digits, unit] <- Regex.run(~r/\A(\d+)(d|w|mo)\z/, string), - {n, ""} <- Integer.parse(digits) do - {:ok, n * unit_seconds(unit)} - else + # The leading-digit guard rejects the signs Integer.parse/1 accepts ("+5d", "-5d") + def duration_to_seconds(<> = string) when digit in ?0..?9 do + case Integer.parse(string) do + {n, unit} when unit in ["d", "w", "mo"] -> {:ok, n * unit_seconds(unit)} _ -> :error end end + def duration_to_seconds(string) when is_binary(string), do: :error + defp unit_seconds("d"), do: 86_400 defp unit_seconds("w"), do: 86_400 * 7 defp unit_seconds("mo"), do: 86_400 * 30 diff --git a/lib/mix/tasks/hex.build.ex b/lib/mix/tasks/hex.build.ex index 2c91011b..453e93d8 100644 --- a/lib/mix/tasks/hex.build.ex +++ b/lib/mix/tasks/hex.build.ex @@ -251,13 +251,14 @@ defmodule Mix.Tasks.Hex.Build do @doc false def package(package, config) do files = package[:files] || Hex.Package.default_files() - exclude_patterns = (package[:exclude_patterns] || []) ++ [~r/\W\.DS_Store$/] + exclude_patterns = package[:exclude_patterns] || [] files = files |> expand_paths(File.cwd!()) |> Enum.reject(fn path -> - Enum.any?(exclude_patterns, &(path =~ &1)) + Path.basename(path) == ".DS_Store" or + Enum.any?(exclude_patterns, &(path =~ &1)) end) package diff --git a/test/hex/cooldown_test.exs b/test/hex/cooldown_test.exs index e61059b6..dd9ad15d 100644 --- a/test/hex/cooldown_test.exs +++ b/test/hex/cooldown_test.exs @@ -37,6 +37,21 @@ defmodule Hex.CooldownTest do assert {:ok, 14 * 86_400} == Cooldown.duration_to_seconds("2w") assert {:ok, 30 * 86_400} == Cooldown.duration_to_seconds("1mo") end + + test "rejects malformed durations" do + assert :error == Cooldown.duration_to_seconds("") + assert :error == Cooldown.duration_to_seconds("5") + assert :error == Cooldown.duration_to_seconds("00") + assert :error == Cooldown.duration_to_seconds("d") + assert :error == Cooldown.duration_to_seconds("5m") + assert :error == Cooldown.duration_to_seconds("5x") + assert :error == Cooldown.duration_to_seconds("5dd") + assert :error == Cooldown.duration_to_seconds("+5d") + assert :error == Cooldown.duration_to_seconds("-5d") + assert :error == Cooldown.duration_to_seconds("1_0d") + assert :error == Cooldown.duration_to_seconds(" 5d") + assert :error == Cooldown.duration_to_seconds("5d ") + end end describe "build_cutoff/0" do diff --git a/test/hex/no_regex_test.exs b/test/hex/no_regex_test.exs new file mode 100644 index 00000000..d8d86772 --- /dev/null +++ b/test/hex/no_regex_test.exs @@ -0,0 +1,23 @@ +defmodule Hex.NoRegexTest do + use ExUnit.Case, async: true + + # Compiled regexes are embedded into beam files, and the compiled PCRE + # pattern format is not portable across OTP versions (for example, + # :re.import/1 does not exist before OTP 28.1). The Hex archive is + # precompiled once and installed on many OTP versions, so code under + # lib/ must not use regexes; parse with pattern matching instead. + test "lib/ contains no regexes" do + paths = Path.wildcard("lib/**/*.ex") + assert paths != [] + + offenders = + Enum.filter(paths, fn path -> + content = File.read!(path) + String.contains?(content, ["~r", "~R", "Regex.", ":re."]) + end) + + assert offenders == [], + "Regex usage found in shipped code: #{inspect(offenders)}. " <> + "Regexes must not appear under lib/, see comment in #{__ENV__.file}" + end +end diff --git a/test/mix/tasks/hex.build_test.exs b/test/mix/tasks/hex.build_test.exs index a4e2938d..8d24f254 100644 --- a/test/mix/tasks/hex.build_test.exs +++ b/test/mix/tasks/hex.build_test.exs @@ -149,6 +149,7 @@ defmodule Mix.Tasks.Hex.BuildTest do File.write!("myfile.txt", "hello") File.write!("executable.sh", "world") File.write!("dir/dir2/test.txt", "and") + File.write!("dir/.DS_Store", "junk") File.chmod!("myfile.txt", 0o100644) File.chmod!("executable.sh", 0o100755) File.chmod!("dir/dir2/test.txt", 0o100644) @@ -170,6 +171,7 @@ defmodule Mix.Tasks.Hex.BuildTest do assert File.read!("unzip/myfile.txt") == "hello" assert File.read!("unzip/dir/.dotfile") == "" assert File.read!("unzip/dir/dir2/test.txt") == "and" + refute File.exists?("unzip/dir/.DS_Store") assert File.stat!("unzip/myfile.txt").mode == 0o100644 assert File.stat!("unzip/executable.sh").mode == 0o100755 end)