Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion lib/tzdata/data_loader.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
38 changes: 38 additions & 0 deletions lib/tzdata/http_client/req.ex
Original file line number Diff line number Diff line change
@@ -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
5 changes: 4 additions & 1 deletion lib/tzdata/tzdata_app.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -44,7 +44,7 @@ defmodule Tzdata.Mixfile do
[
autoupdate: :enabled,
data_dir: nil,
http_client: Tzdata.HTTPClient.Hackney
http_client: Tzdata.HTTPClient.Req
]
end

Expand Down
17 changes: 9 additions & 8 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -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"},
}
68 changes: 68 additions & 0 deletions test/integration/req_download_test.exs
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions test/tzdata/finch_pool_test.exs
Original file line number Diff line number Diff line change
@@ -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
74 changes: 74 additions & 0 deletions test/tzdata/http_client/req_test.exs
Original file line number Diff line number Diff line change
@@ -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