diff --git a/README.md b/README.md index afcb36a..9a268e7 100644 --- a/README.md +++ b/README.md @@ -97,9 +97,28 @@ For use with [Calendar](https://github.com/lau/calendar) you can still specify tzdata ~> 0.1.7 in your mix.exs file in case you experience problems using version ~> 0.5.20 -## Hackney dependency and security +## HTTP Client -Tzdata depends on Hackney in order to do HTTPS requests to get new updates. This is done because Erlang's built in HTTP client `httpc` does not verify SSL certificates when doing HTTPS requests. Hackney verifies the certificate of IANA when getting new tzdata releases from IANA. +Tzdata uses Req (via the Finch HTTP client) for HTTPS requests to get new updates. + +### Using Hackney (Legacy) + +If you need to continue using Hackney, you can configure it explicitly: + +```elixir +# mix.exs +defp deps do + [ + {:tzdata, "~> 1.2"}, + {:hackney, "~> 1.0"} + ] +end + +# config/config.exs +config :tzdata, http_client: Tzdata.HTTPClient.Hackney +``` + +Note: Hackney has known security vulnerabilities and is less actively maintained than Req/Finch. ## Documentation diff --git a/lib/tzdata/data_loader.ex b/lib/tzdata/data_loader.ex index 192dd54..eec918e 100644 --- a/lib/tzdata/data_loader.ex +++ b/lib/tzdata/data_loader.ex @@ -171,6 +171,6 @@ defmodule Tzdata.DataLoader do defp data_dir, do: Tzdata.Util.data_dir() defp http_client() do - Application.get_env(:tzdata, :http_client, Tzdata.HTTPClient.Hackney) + Application.get_env(:tzdata, :http_client, Tzdata.HTTPClient.Req) end end diff --git a/lib/tzdata/http_client/req.ex b/lib/tzdata/http_client/req.ex new file mode 100644 index 0000000..8a93184 --- /dev/null +++ b/lib/tzdata/http_client/req.ex @@ -0,0 +1,38 @@ +defmodule Tzdata.HTTPClient.Req do + @moduledoc false + + @behaviour Tzdata.HTTPClient + + @impl true + def get(url, headers, options) do + follow_redirect = Keyword.get(options, :follow_redirect, false) + + req_options = [ + headers: headers, + redirect: follow_redirect, + decode_body: false + ] + + case Req.request([method: :get, url: url] ++ req_options) do + {:ok, %Req.Response{status: status, headers: response_headers, body: body}} -> + # Convert headers to list of tuples to match HTTPClient behavior + headers_list = Enum.map(response_headers, fn {k, v} -> {k, List.first(v) || v} end) + {:ok, {status, headers_list, body}} + + {:error, reason} -> + {:error, reason} + end + end + + @impl true + def head(url, headers, _options) do + case Req.request(method: :head, url: url, headers: headers) do + {:ok, %Req.Response{status: status, headers: response_headers}} -> + headers_list = Enum.map(response_headers, fn {k, v} -> {k, List.first(v) || v} end) + {:ok, {status, headers_list}} + + {:error, reason} -> + {:error, reason} + end + end +end diff --git a/lib/tzdata/tzdata_app.ex b/lib/tzdata/tzdata_app.ex index d060e95..bec5070 100644 --- a/lib/tzdata/tzdata_app.ex +++ b/lib/tzdata/tzdata_app.ex @@ -4,7 +4,10 @@ defmodule Tzdata.App do use Application def start(_type, _args) do - children = [Tzdata.EtsHolder] + children = [ + {Finch, name: Tzdata.Finch}, + Tzdata.EtsHolder + ] children = case Application.fetch_env(:tzdata, :autoupdate) do {:ok, :enabled} -> children ++ [Tzdata.ReleaseUpdater] {:ok, :disabled} -> children diff --git a/mix.exs b/mix.exs index 78d1e6f..88f90ad 100644 --- a/mix.exs +++ b/mix.exs @@ -27,7 +27,7 @@ defmodule Tzdata.Mixfile do defp deps do [ - {:hackney, "~> 1.17"}, + {:req, "~> 0.5"}, {:ex_doc, "~> 0.21", only: :dev, runtime: false} ] end @@ -44,7 +44,7 @@ defmodule Tzdata.Mixfile do [ autoupdate: :enabled, data_dir: nil, - http_client: Tzdata.HTTPClient.Hackney + http_client: Tzdata.HTTPClient.Req ] end diff --git a/mix.lock b/mix.lock index c278e8a..dbfaec5 100644 --- a/mix.lock +++ b/mix.lock @@ -1,16 +1,17 @@ %{ - "certifi": {:hex, :certifi, "2.14.0", "ed3bef654e69cde5e6c022df8070a579a79e8ba2368a00acf3d75b82d9aceeed", [:rebar3], [], "hexpm", "ea59d87ef89da429b8e905264fdec3419f84f2215bb3d81e07a18aac919026c3"}, "earmark_parser": {:hex, :earmark_parser, "1.4.43", "34b2f401fe473080e39ff2b90feb8ddfeef7639f8ee0bbf71bb41911831d77c5", [:mix], [], "hexpm", "970a3cd19503f5e8e527a190662be2cee5d98eed1ff72ed9b3d1a3d466692de8"}, "ex_doc": {:hex, :ex_doc, "0.37.2", "2a3aa7014094f0e4e286a82aa5194a34dd17057160988b8509b15aa6c292720c", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "4dfa56075ce4887e4e8b1dcc121cd5fcb0f02b00391fd367ff5336d98fa49049"}, - "hackney": {:hex, :hackney, "1.23.0", "55cc09077112bcb4a69e54be46ed9bc55537763a96cd4a80a221663a7eafd767", [:rebar3], [{:certifi, "~> 2.14.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "6cd1c04cd15c81e5a493f167b226a15f0938a84fc8f0736ebe4ddcab65c0b44e"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, - "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, } diff --git a/test/integration/req_download_test.exs b/test/integration/req_download_test.exs new file mode 100644 index 0000000..f7a33fa --- /dev/null +++ b/test/integration/req_download_test.exs @@ -0,0 +1,68 @@ +defmodule Tzdata.Integration.ReqDownloadTest do + use ExUnit.Case + + @moduletag :integration + @moduletag timeout: 60_000 + + alias Tzdata.HTTPClient.Req, as: ReqClient + + @iana_url "https://data.iana.org/time-zones/tzdata-latest.tar.gz" + + describe "Req adapter with real IANA data" do + test "can perform HEAD request to get content-length" do + assert {:ok, {status, headers}} = ReqClient.head(@iana_url, [], []) + assert status == 200 + + content_length = + headers + |> Enum.find(fn {k, _v} -> String.downcase(k) == "content-length" end) + |> elem(1) + |> String.to_integer() + + assert content_length > 100_000 # Reasonable size for tzdata archive + end + + test "can perform HEAD request to get last-modified header" do + assert {:ok, {status, headers}} = ReqClient.head(@iana_url, [], []) + assert status == 200 + + last_modified = + Enum.find_value(headers, fn {k, v} -> + if String.downcase(k) == "last-modified", do: v + end) + + assert is_binary(last_modified) + assert String.length(last_modified) > 0 + end + + test "can download actual IANA tzdata file" do + assert {:ok, {status, _headers, body}} = ReqClient.get(@iana_url, [], []) + assert status == 200 + assert is_binary(body) + assert byte_size(body) > 100_000 # Reasonable size for tzdata archive + + # Verify it's a gzip file + assert binary_part(body, 0, 2) == <<0x1F, 0x8B>> + end + + test "handles follow_redirect option correctly" do + # Test with a URL that redirects (if IANA uses redirects) + assert {:ok, {status, _headers, body}} = + ReqClient.get(@iana_url, [], [follow_redirect: true]) + + assert status == 200 + assert is_binary(body) + assert byte_size(body) > 100_000 + end + + test "sends custom headers in real request" do + custom_headers = [{"User-Agent", "tzdata-test"}] + + assert {:ok, {status, _headers, body}} = + ReqClient.get(@iana_url, custom_headers, []) + + assert status == 200 + assert is_binary(body) + end + end +end diff --git a/test/tzdata/finch_pool_test.exs b/test/tzdata/finch_pool_test.exs new file mode 100644 index 0000000..680b4c7 --- /dev/null +++ b/test/tzdata/finch_pool_test.exs @@ -0,0 +1,13 @@ +defmodule Tzdata.FinchPoolTest do + use ExUnit.Case + + test "Finch pool is started and available" do + # Verify the Finch pool is registered + assert Process.whereis(Tzdata.Finch) != nil + + # Verify it responds to a simple request + url = "https://httpbin.org/get" + request = Finch.build(:get, url) + assert {:ok, %Finch.Response{}} = Finch.request(request, Tzdata.Finch) + end +end diff --git a/test/tzdata/http_client/req_test.exs b/test/tzdata/http_client/req_test.exs new file mode 100644 index 0000000..2781728 --- /dev/null +++ b/test/tzdata/http_client/req_test.exs @@ -0,0 +1,74 @@ +defmodule Tzdata.HTTPClient.ReqTest do + use ExUnit.Case, async: false + + alias Tzdata.HTTPClient.Req, as: ReqClient + + @moduletag :req + + describe "get/3" do + test "successfully performs GET request" do + url = "https://httpbin.org/get" + headers = [] + options = [] + + assert {:ok, {status, response_headers, body}} = ReqClient.get(url, headers, options) + assert status == 200 + assert is_list(response_headers) + assert is_binary(body) + assert body =~ "httpbin" + end + + test "follows redirects when follow_redirect is true" do + url = "https://httpbin.org/redirect/1" + headers = [] + options = [follow_redirect: true] + + assert {:ok, {status, response_headers, body}} = ReqClient.get(url, headers, options) + assert status == 200 + assert is_list(response_headers) + assert is_binary(body) + end + + test "does not follow redirects when follow_redirect is false" do + url = "https://httpbin.org/redirect/1" + headers = [] + options = [follow_redirect: false] + + assert {:ok, {status, response_headers, _body}} = ReqClient.get(url, headers, options) + assert status in [301, 302, 307, 308] + # Should have location header + assert Enum.any?(response_headers, fn {k, _v} -> String.downcase(k) == "location" end) + end + + test "sends custom headers in request" do + url = "https://httpbin.org/headers" + headers = [{"X-Custom-Header", "test-value"}] + options = [] + + assert {:ok, {status, _response_headers, body}} = ReqClient.get(url, headers, options) + assert status == 200 + assert body =~ "X-Custom-Header" + assert body =~ "test-value" + end + end + + describe "head/3" do + test "successfully performs HEAD request" do + url = "https://httpbin.org/get" + headers = [] + options = [] + + assert {:ok, {status, response_headers}} = ReqClient.head(url, headers, options) + assert status == 200 + assert is_list(response_headers) + end + + test "returns error for invalid URL" do + url = "https://this-domain-does-not-exist-12345.com" + headers = [] + options = [] + + assert {:error, _reason} = ReqClient.head(url, headers, options) + end + end +end