Skip to content

Commit 1f9550a

Browse files
authored
Add support for reading credentials from .netrc files (#925)
1 parent 19860cf commit 1f9550a

9 files changed

Lines changed: 447 additions & 2 deletions

File tree

lib/hex/application.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ defmodule Hex.Application do
7474
if Mix.env() == :test do
7575
defp children do
7676
[
77+
worker(Hex.Netrc.Cache),
7778
worker(Hex.State),
7879
worker(Hex.Server),
7980
worker(Hex.Parallel, [:hex_fetcher])
@@ -82,6 +83,7 @@ defmodule Hex.Application do
8283
else
8384
defp children do
8485
[
86+
worker(Hex.Netrc.Cache),
8587
worker(Hex.State),
8688
worker(Hex.Server),
8789
worker(Hex.Parallel, [:hex_fetcher]),

lib/hex/http.ex

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ defmodule Hex.HTTP do
66
@request_retries 2
77

88
def request(method, url, headers, body, opts \\ []) do
9-
headers = build_headers(headers)
9+
headers =
10+
headers
11+
|> build_headers()
12+
|> add_basic_auth_via_netrc(url)
1013

1114
timeout =
1215
opts[:timeout] ||
@@ -295,4 +298,19 @@ defmodule Hex.HTTP do
295298
|> skip_ws()
296299
|> skip_trail_ws("", "")
297300
end
301+
302+
defp add_basic_auth_via_netrc(%{'authorization' => _} = headers, _url), do: headers
303+
304+
defp add_basic_auth_via_netrc(%{} = headers, url) do
305+
url = URI.parse(url)
306+
307+
case Hex.Netrc.lookup(url.host) do
308+
{:ok, %{username: username, password: password}} ->
309+
base64 = :base64.encode_to_string("#{username}:#{password}")
310+
Map.put(headers, 'authorization', 'Basic #{base64}')
311+
312+
_ ->
313+
headers
314+
end
315+
end
298316
end

lib/hex/netrc.ex

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
defmodule Hex.Netrc do
2+
@moduledoc false
3+
4+
alias Hex.Netrc.Cache
5+
alias Hex.Netrc.Parser
6+
7+
def lookup(host, path \\ Parser.netrc_path()) when is_binary(host) and is_binary(path) do
8+
case Cache.fetch(path) do
9+
{:ok, %{} = machines} ->
10+
{:ok, Map.get(machines, host)}
11+
12+
other ->
13+
other
14+
end
15+
end
16+
end

lib/hex/netrc/cache.ex

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
defmodule Hex.Netrc.Cache do
2+
@moduledoc false
3+
4+
alias Hex.Netrc.Parser
5+
6+
@agent_name __MODULE__
7+
8+
def start_link(_arg) do
9+
Agent.start_link(fn -> %{} end, name: @agent_name)
10+
end
11+
12+
def child_spec(arg) do
13+
%{
14+
id: __MODULE__,
15+
start: {__MODULE__, :start_link, [arg]}
16+
}
17+
end
18+
19+
def fetch(path \\ Parser.netrc_path()) when is_binary(path) do
20+
Agent.get_and_update(@agent_name, fn cache ->
21+
case Map.fetch(cache, path) do
22+
{:ok, cached_parse_result} ->
23+
{cached_parse_result, cache}
24+
25+
:error ->
26+
parse_result = Parser.parse(path)
27+
{parse_result, Map.put(cache, path, parse_result)}
28+
end
29+
end)
30+
end
31+
end

lib/hex/netrc/parser.ex

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
defmodule Hex.Netrc.Parser do
2+
@moduledoc false
3+
4+
def parse(path \\ netrc_path()) when is_binary(path) do
5+
case File.read(path) do
6+
{:ok, contents} ->
7+
parse_contents(contents)
8+
9+
error ->
10+
error
11+
end
12+
end
13+
14+
defp parse_contents(contents) when is_binary(contents) do
15+
parse_result =
16+
contents
17+
|> Hex.Stdlib.string_trim()
18+
|> String.split("\n", trim: true)
19+
|> Enum.map(&String.split/1)
20+
|> Enum.reduce({%{}, nil}, &parse_line/2)
21+
22+
case parse_result do
23+
{machines, %{username: _, password: _} = current} ->
24+
{host, machine} = Map.pop(current, :host)
25+
{:ok, Map.put(machines, host, machine)}
26+
27+
_ ->
28+
{:error, :parse}
29+
end
30+
end
31+
32+
defp parse_line(_, :parse_error), do: :parse_error
33+
34+
defp parse_line(["machine", host], {machines, nil}) do
35+
{machines, %{host: host}}
36+
end
37+
38+
defp parse_line(["machine", next_host], {machines, %{username: _, password: _} = current}) do
39+
{host, machine} = Map.pop(current, :host)
40+
{Map.put(machines, host, machine), %{host: next_host}}
41+
end
42+
43+
defp parse_line(["login", username], {machines, %{} = current}) do
44+
{machines, Map.put(current, :username, username)}
45+
end
46+
47+
defp parse_line(["password", password], {machines, %{} = current}) do
48+
{machines, Map.put(current, :password, password)}
49+
end
50+
51+
defp parse_line(_line, _parse_state), do: :parse_error
52+
53+
def netrc_path() do
54+
System.get_env("NETRC") || default_path()
55+
end
56+
57+
defp default_path() do
58+
Path.join(System.user_home!(), ".netrc")
59+
end
60+
end

test/hex/http_test.exs

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@ defmodule Hex.HTTPTest do
88
Enum.map([:proxy, :https_proxy], fn opt ->
99
:httpc.set_options([{opt, {{'localhost', 80}, ['localhost']}}], :hex)
1010
end)
11+
12+
System.delete_env("NETRC")
1113
end)
1214

13-
:ok
15+
bypass = Bypass.open()
16+
{:ok, bypass: bypass}
1417
end
1518

1619
test "proxy_config returns no credentials when no proxy supplied" do
@@ -51,4 +54,65 @@ defmodule Hex.HTTPTest do
5154
Hex.HTTP.handle_hex_message('"oops, you done goofed";level=fatal ')
5255
assert_received {:mix_shell, :error, ["API error: oops, you done goofed"]}
5356
end
57+
58+
test "request adds no authorization header if none is given and no netrc is found", %{
59+
bypass: bypass
60+
} do
61+
in_tmp(fn ->
62+
Bypass.expect(bypass, fn conn ->
63+
assert Plug.Conn.get_req_header(conn, "authorization") == []
64+
Plug.Conn.resp(conn, 200, "")
65+
end)
66+
67+
Hex.HTTP.request(:get, "http://localhost:#{bypass.port}", [], nil)
68+
end)
69+
end
70+
71+
test "request adds authorization header based on netrc if none is given", %{bypass: bypass} do
72+
in_tmp(fn ->
73+
File.write!(".netrc", """
74+
machine localhost
75+
login john
76+
password doe
77+
""")
78+
79+
System.put_env("NETRC", Path.join(File.cwd!(), ".netrc"))
80+
81+
Bypass.expect(bypass, fn conn ->
82+
assert Plug.Conn.get_req_header(conn, "authorization") == [
83+
"Basic #{:base64.encode("john:doe")}"
84+
]
85+
86+
Plug.Conn.resp(conn, 200, "")
87+
end)
88+
89+
Hex.HTTP.request(:get, "http://localhost:#{bypass.port}", [], nil)
90+
end)
91+
end
92+
93+
test "request adds no authorization header based on netrc if authorization is given", %{
94+
bypass: bypass
95+
} do
96+
in_tmp(fn ->
97+
File.write!(".netrc", """
98+
machine localhost
99+
login john
100+
password doe
101+
""")
102+
103+
System.put_env("NETRC", Path.join(File.cwd!(), ".netrc"))
104+
105+
Bypass.expect(bypass, fn conn ->
106+
assert Plug.Conn.get_req_header(conn, "authorization") == ["myAuthHeader"]
107+
Plug.Conn.resp(conn, 200, "")
108+
end)
109+
110+
Hex.HTTP.request(
111+
:get,
112+
"http://localhost:#{bypass.port}",
113+
[{'authorization', 'myAuthHeader'}],
114+
nil
115+
)
116+
end)
117+
end
54118
end

test/hex/netrc/cache_test.exs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
defmodule Hex.Netrc.CacheTest do
2+
use HexTest.Case, async: false
3+
4+
alias Hex.Netrc.Cache
5+
6+
setup do
7+
# Restart Hex between tests to get a fresh cache.
8+
Application.stop(:hex)
9+
:ok = Application.start(:hex)
10+
end
11+
12+
test "fetch/1 fails on non-existent file" do
13+
in_tmp(fn ->
14+
assert {:error, :enoent} = Cache.fetch(".netrc")
15+
end)
16+
end
17+
18+
test "fetch/1 remembers parse errors" do
19+
in_tmp(fn ->
20+
File.write!(".netrc", "")
21+
assert {:error, :parse} = Cache.fetch(".netrc")
22+
File.rm!(".netrc")
23+
assert {:error, :parse} = Cache.fetch(".netrc")
24+
end)
25+
end
26+
27+
test "fetch/1 succeeds on simple file" do
28+
in_tmp(fn ->
29+
data = """
30+
machine foo.example.com
31+
login john
32+
password bar
33+
"""
34+
35+
parsed = %{
36+
"foo.example.com" => %{
37+
username: "john",
38+
password: "bar"
39+
}
40+
}
41+
42+
File.write!(".netrc", data)
43+
assert {:ok, ^parsed} = Cache.fetch(".netrc")
44+
end)
45+
end
46+
47+
test "fetch/1 remembers successful parses" do
48+
in_tmp(fn ->
49+
data = """
50+
machine foo.example.com
51+
login john
52+
password bar
53+
"""
54+
55+
parsed = %{
56+
"foo.example.com" => %{
57+
username: "john",
58+
password: "bar"
59+
}
60+
}
61+
62+
File.write!(".netrc", data)
63+
assert {:ok, ^parsed} = Cache.fetch(".netrc")
64+
File.rm!(".netrc")
65+
assert {:ok, ^parsed} = Cache.fetch(".netrc")
66+
end)
67+
end
68+
end

0 commit comments

Comments
 (0)