defmodule GitGudWeb.HexRegistryTest do
@moduledoc """
Phase 2: the Hex registry v2 repository, over HTTP.
The assertions that matter here are the ones `mix` makes: the
payload decodes, the signature verifies against the published public
key, and the `repository` field matches the name the repo was added
under — `mix` rejects the registry outright if any of those is off.
"""
use GitGudWeb.ConnCase, async: false
alias GitGud.Packages
alias GitGud.Packages.Formats.Hex, as: HexFormat
alias GitGud.Packages.Formats.Hex.Pb
alias GitGud.Packages.Formats.Hex.Tarball
alias GitGud.Repositories.Storage
import GitGud.ForgeFixtures
setup do
{user, repo} = repository_fixture(%{visibility: "public"})
{:ok, _t, token} = Packages.create_registry_token(repo, "ci", ["pull", "push"])
{:ok,
user: user,
repo: repo,
token: token,
root: "/pkg/hex/#{Storage.owner_handle(user)}/#{repo.name}"}
end
# A published Hex tarball: an uncompressed .tar of VERSION, CHECKSUM,
# metadata.config and contents.tar.gz.
defp hex_tarball(name, version, opts \\ []) do
requirements = Keyword.get(opts, :requirements, [])
contents = :zlib.gzip("the package source")
config =
[
~s({<<"name">>, <<"#{name}">>}.),
~s({<<"version">>, <<"#{version}">>}.),
~s({<<"app">>, <<"#{name}">>}.),
~s({<<"description">>, <<"a test package">>}.),
requirements_term(requirements)
]
|> Enum.join("\n")
inner = :crypto.hash(:sha256, "3" <> config <> contents)
members = [
{~c"VERSION", "3"},
{~c"CHECKSUM", Base.encode16(inner, case: :upper)},
{~c"metadata.config", config},
{~c"contents.tar.gz", contents}
]
path = Path.join(System.tmp_dir!(), "hex-#{System.unique_integer([:positive])}.tar")
:ok = :erl_tar.create(String.to_charlist(path), members, [])
File.read!(path)
end
defp requirements_term([]), do: ~s({<<"requirements">>, []}.)
defp requirements_term(reqs) do
entries =
Enum.map_join(reqs, ",", fn {pkg, req} ->
~s([{<<"name">>,<<"#{pkg}">>},{<<"app">>,<<"#{pkg}">>},) <>
~s({<<"optional">>,false},{<<"requirement">>,<<"#{req}">>},) <>
~s({<<"repository">>,<<"hexpm">>}])
end)
~s({<<"requirements">>, [#{entries}]}.)
end
defp publish(conn, root, token, tarball) do
conn
|> put_req_header("authorization", token)
|> put_req_header("content-type", "application/octet-stream")
|> post(root <> "/publish", tarball)
end
defp public_key(conn, root) do
get(conn, root <> "/public_key").resp_body
end
# Exactly what a client does: gunzip, decode Signed, verify the RSA
# signature over the SHA-512 digest of the payload, then decode.
defp fetch_signed(conn, root, path, module, message) do
pem = public_key(build_conn(), root)
body = get(conn, root <> path).resp_body
{payload, verified?} = HexFormat.verify(body, pem)
assert verified?, "#{path} did not verify against the published public key"
{module.decode(payload), message}
|> elem(0)
end
describe "/public_key" do
test "serves a PEM the registry's own signatures verify against", %{conn: conn, root: root} do
pem = public_key(conn, root)
assert pem =~ "-----BEGIN PUBLIC KEY-----"
assert [{:SubjectPublicKeyInfo, _, _}] = :public_key.pem_decode(pem)
end
test "is stable across requests — a rotating key breaks every consumer", %{
conn: conn,
root: root
} do
assert public_key(conn, root) == public_key(build_conn(), root)
end
end
describe "/names" do
test "lists published packages, signed", %{conn: conn, repo: repo, root: root, token: token} do
publish(conn, root, token, hex_tarball("widget", "1.0.0"))
names = fetch_signed(build_conn(), root, "/names", Pb.Names, Pb.Names)
assert names.repository == HexFormat.repo_name(repo)
assert Enum.map(names.packages, & &1.name) == ["widget"]
end
test "the repository field matches the name mix must add it under", %{
conn: conn,
repo: repo,
root: root,
token: token
} do
publish(conn, root, token, hex_tarball("widget", "1.0.0"))
names = fetch_signed(build_conn(), root, "/names", Pb.Names, Pb.Names)
# `mix` rejects the whole registry when these disagree.
assert names.repository == HexFormat.repo_name(repo)
assert names.repository =~ repo.name
end
test "a tampered payload fails verification", %{conn: conn, root: root, token: token} do
publish(conn, root, token, hex_tarball("widget", "1.0.0"))
pem = public_key(build_conn(), root)
%Pb.Signed{} =
signed =
get(build_conn(), root <> "/names").resp_body |> :zlib.gunzip() |> Pb.Signed.decode()
forged =
%Pb.Signed{signed | payload: Pb.Names.encode(%Pb.Names{repository: "evil", packages: []})}
|> Pb.Signed.encode()
|> :zlib.gzip()
assert {_payload, false} = HexFormat.verify(forged, pem)
end
end
describe "/versions" do
test "lists versions in order with retired entries by index", %{
conn: conn,
repo: repo,
root: root,
token: token
} do
for v <- ["1.0.0", "1.2.0", "1.10.0"] do
publish(build_conn(), root, token, hex_tarball("widget", v))
end
pkg = Packages.get_package(repo, "hex", "widget")
{:ok, _} = Packages.set_yanked(Packages.get_version(pkg, "1.2.0"), true, "bad build")
versions = fetch_signed(conn, root, "/versions", Pb.Versions, Pb.Versions)
assert [package] = versions.packages
# Semver order, not lexicographic — 1.10.0 sorts after 1.2.0.
assert package.versions == ["1.0.0", "1.2.0", "1.10.0"]
# Positional: retirement points at an index in `versions`.
assert package.retired == [1]
end
end
describe "/packages/<name>" do
test "carries checksums and dependencies", %{conn: conn, root: root, token: token} do
tarball = hex_tarball("widget", "1.0.0", requirements: [{"jason", "~> 1.0"}])
publish(conn, root, token, tarball)
package = fetch_signed(build_conn(), root, "/packages/widget", Pb.Package, Pb.Package)
assert [release] = package.releases
assert release.version == "1.0.0"
# Checksums go out as raw bytes, not hex.
assert byte_size(release.inner_checksum) == 32
assert release.outer_checksum == :crypto.hash(:sha256, tarball)
assert [dep] = release.dependencies
assert dep.package == "jason"
assert dep.requirement == "~> 1.0"
refute dep.optional
end
test "a yanked release is retired, not removed", %{
conn: conn,
repo: repo,
root: root,
token: token
} do
publish(conn, root, token, hex_tarball("widget", "1.0.0"))
pkg = Packages.get_package(repo, "hex", "widget")
{:ok, _} = Packages.set_yanked(Packages.get_version(pkg, "1.0.0"), true, "bad build")
package = fetch_signed(build_conn(), root, "/packages/widget", Pb.Package, Pb.Package)
assert [%Pb.Release{retired: retired}] = package.releases
assert retired.reason == :RETIRED_OTHER
assert retired.message == "bad build"
end
test "404 for an unpublished package", %{conn: conn, root: root} do
assert get(conn, root <> "/packages/nothing").status == 404
end
end
describe "/tarballs" do
test "serves the published bytes back verbatim", %{conn: conn, root: root, token: token} do
tarball = hex_tarball("widget", "1.0.0")
publish(conn, root, token, tarball)
conn = get(build_conn(), root <> "/tarballs/widget-1.0.0.tar")
assert conn.status == 200
assert conn.resp_body == tarball
end
end
describe "publishing" do
test "derives name, version and dependencies from the tarball", %{
conn: conn,
repo: repo,
root: root,
token: token
} do
conn =
publish(
conn,
root,
token,
hex_tarball("widget", "1.0.0", requirements: [{"jason", "~> 1.0"}])
)
assert conn.status == 200
assert %{"name" => "widget", "version" => "1.0.0"} = Jason.decode!(conn.resp_body)
pkg = Packages.get_package(repo, "hex", "widget")
version = Packages.get_version(pkg, "1.0.0")
assert [%{"package" => "jason", "requirement" => "~> 1.0"}] =
version.metadata["dependencies"]
end
test "rejects something that isn't a tar", %{conn: conn, root: root, token: token} do
conn = publish(conn, root, token, "definitely not a tarball")
assert conn.status == 400
assert conn.resp_body =~ "tar"
end
test "rejects a tar with no metadata.config", %{conn: conn, root: root, token: token} do
path = Path.join(System.tmp_dir!(), "empty-#{System.unique_integer([:positive])}.tar")
:ok = :erl_tar.create(String.to_charlist(path), [{~c"VERSION", "3"}], [])
conn = publish(conn, root, token, File.read!(path))
assert conn.status == 400
assert conn.resp_body =~ "metadata.config"
end
test "an anonymous publish is refused", %{conn: conn, root: root} do
conn =
conn
|> put_req_header("content-type", "application/octet-stream")
|> post(root <> "/publish", hex_tarball("widget", "1.0.0"))
assert conn.status == 401
end
test "409 on republishing a version with different bytes", %{
conn: conn,
root: root,
token: token
} do
publish(conn, root, token, hex_tarball("widget", "1.0.0"))
conn =
publish(
build_conn(),
root,
token,
hex_tarball("widget", "1.0.0", requirements: [{"jason", "~> 1.0"}])
)
assert conn.status == 409
end
end
describe "Tarball.parse/1" do
test "reads the inner checksum out of CHECKSUM rather than recomputing it" do
tarball = hex_tarball("widget", "1.0.0")
assert {:ok, meta} = Tarball.parse(tarball)
assert meta.name == "widget"
assert meta.version == "1.0.0"
assert meta.app == "widget"
assert meta.description == "a test package"
assert String.match?(meta.inner_checksum, ~r/\A[0-9a-f]{64}\z/)
end
test "reads the older proplist-keyed requirements shape too" do
contents = :zlib.gzip("source")
config = """
{<<"name">>, <<"legacy">>}.
{<<"version">>, <<"0.1.0">>}.
{<<"requirements">>, [{<<"jason">>, [{<<"requirement">>, <<"~> 1.0">>}, {<<"optional">>, false}]}]}.
"""
inner = :crypto.hash(:sha256, "3" <> config <> contents)
path = Path.join(System.tmp_dir!(), "legacy-#{System.unique_integer([:positive])}.tar")
:ok =
:erl_tar.create(
String.to_charlist(path),
[
{~c"VERSION", "3"},
{~c"CHECKSUM", Base.encode16(inner, case: :upper)},
{~c"metadata.config", config},
{~c"contents.tar.gz", contents}
],
[]
)
assert {:ok, meta} = Tarball.parse(File.read!(path))
assert [%{"package" => "jason", "requirement" => "~> 1.0"}] = meta.dependencies
end
test "metadata.config is parsed as data — a function call is not evaluated" do
contents = :zlib.gzip("source")
config = "{<<\"name\">>, os:cmd(\"touch /tmp/pwned\")}.\n"
inner = :crypto.hash(:sha256, "3" <> config <> contents)
path = Path.join(System.tmp_dir!(), "evil-#{System.unique_integer([:positive])}.tar")
:ok =
:erl_tar.create(
String.to_charlist(path),
[
{~c"VERSION", "3"},
{~c"CHECKSUM", Base.encode16(inner, case: :upper)},
{~c"metadata.config", config},
{~c"contents.tar.gz", contents}
],
[]
)
assert {:error, {400, _}} = Tarball.parse(File.read!(path))
end
end
describe "visibility" do
test "a private registry challenges an anonymous client" do
{user, private} = repository_fixture(%{visibility: "private"})
root = "/pkg/hex/#{Storage.owner_handle(user)}/#{private.name}"
conn = get(build_conn(), root <> "/names")
assert conn.status == 401
assert [_] = get_resp_header(conn, "www-authenticate")
end
end
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
12.1 KiB · text
History
b868907