defmodule GitGudWeb.AptRegistryTest do
@moduledoc """
Phase 4: the Debian repository, over HTTP.
The assertions here are apt's: a signature it can verify against the
published key, `Release` fields it requires, checksums for the
uncompressed index as well as the compressed one, and
`Description-md5`, which apt has refused to do without since 2.7.7.
"""
use GitGudWeb.ConnCase, async: false
alias GitGud.Packages
alias GitGud.Packages.Apt.Native
alias GitGud.Packages.Formats.Deb.Control
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/deb/#{Storage.owner_handle(user)}/#{repo.name}"}
end
# A .deb: an ar archive of debian-binary, control.tar.<ext> and
# data.tar.gz, in that order.
defp deb(fields, opts \\ []) do
compression = Keyword.get(opts, :compression, :gzip)
control =
fields
|> Enum.map_join(fn {k, v} -> "#{k}: #{v}\n" end)
control_tar = tar([{~c"./control", control}])
control_member =
case compression do
:gzip -> {"control.tar.gz", :zlib.gzip(control_tar)}
:xz -> {"control.tar.xz", elem(Native.xz_compress(control_tar), 1)}
:none -> {"control.tar", control_tar}
end
ar([
{"debian-binary", "2.0\n"},
control_member,
{"data.tar.gz", :zlib.gzip(tar([{~c"./usr/bin/thing", "#!/bin/sh\n"}]))}
])
end
defp tar(members) do
path = Path.join(System.tmp_dir!(), "t-#{System.unique_integer([:positive])}.tar")
:ok = :erl_tar.create(String.to_charlist(path), members, [])
File.read!(path)
end
# 60-byte fixed-width ASCII headers, data padded to an even offset.
defp ar(members) do
body =
Enum.map_join(members, fn {name, data} ->
header =
String.pad_trailing(name, 16) <>
String.pad_trailing("0", 12) <>
String.pad_trailing("0", 6) <>
String.pad_trailing("0", 6) <>
String.pad_trailing("100644", 8) <>
String.pad_trailing(Integer.to_string(byte_size(data)), 10) <>
<<0x60, 0x0A>>
padding = if rem(byte_size(data), 2) == 1, do: "\n", else: ""
header <> data <> padding
end)
"!<arch>\n" <> body
end
defp control_fields(overrides \\ []) do
Keyword.merge(
[
Package: "widget",
Version: "1.0.0-1",
Architecture: "amd64",
Maintainer: "Someone <someone@example.com>",
"Installed-Size": "128",
Section: "utils",
Priority: "optional",
Depends: "libc6",
Description: "a widget"
],
overrides
)
end
defp upload(conn, root, token, fields, opts \\ []) do
query = Keyword.get(opts, :query, "")
conn = if token, do: put_req_header(conn, "authorization", token), else: conn
conn
|> put_req_header("content-type", "application/octet-stream")
|> post(root <> "/upload" <> query, deb(fields, opts))
end
describe "Control.parse/1" do
test "reads a gzip-compressed control member" do
assert {:ok, control} = Control.parse(deb(control_fields()))
assert control["Package"] == "widget"
assert control["Version"] == "1.0.0-1"
assert control["Architecture"] == "amd64"
end
test "reads an xz-compressed one, which is what dpkg actually produces" do
assert {:ok, control} = Control.parse(deb(control_fields(), compression: :xz))
assert control["Package"] == "widget"
end
test "reads an uncompressed one" do
assert {:ok, control} = Control.parse(deb(control_fields(), compression: :none))
assert control["Package"] == "widget"
end
test "keeps a folded Description together" do
control = """
Package: widget
Version: 1.0.0
Description: a widget
it does widget things
across several lines
"""
assert {:ok, fields} = Control.parse_stanza(control)
assert fields["Description"] ==
"a widget\n it does widget things\n across several lines"
end
test "rejects something that isn't an ar archive" do
assert {:error, {400, message}} = Control.parse("not a deb at all")
assert message =~ "!<arch>"
end
test "rejects a stanza with no Package" do
assert {:error, {400, _}} = Control.parse_stanza("Version: 1.0\n")
end
test "description_md5 hashes the newline-terminated body" do
assert Control.description_md5("a widget") ==
Base.encode16(:crypto.hash(:md5, "a widget\n"), case: :lower)
# Trailing newlines are normalised, not doubled.
assert Control.description_md5("a widget\n") == Control.description_md5("a widget")
end
end
describe "the signing key" do
test "is an armored OpenPGP certificate", %{conn: conn, root: root} do
conn = get(conn, root <> "/key.asc")
assert conn.status == 200
assert conn.resp_body =~ "-----BEGIN PGP PUBLIC KEY BLOCK-----"
assert get_resp_header(conn, "content-type") == ["application/pgp-keys"]
end
test "is stable — a rotating key breaks every apt client", %{conn: conn, root: root} do
assert get(conn, root <> "/key.asc").resp_body ==
get(build_conn(), root <> "/key.asc").resp_body
end
end
describe "Release" do
setup %{conn: conn, root: root, token: token} do
upload(conn, root, token, control_fields())
:ok
end
test "carries the fields the format makes mandatory", %{conn: conn, root: root} do
body = get(conn, root <> "/dists/stable/Release").resp_body
assert body =~ ~r/^Suite: stable$/m
assert body =~ ~r/^Codename: stable$/m
assert body =~ ~r/^Architectures: .*amd64/m
assert body =~ ~r/^Components: main$/m
assert body =~ ~r/^Date: \w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} UTC$/m
assert body =~ ~r/^SHA256:$/m
end
test "offers no MD5Sum or SHA1 block — SHA256 is the floor", %{conn: conn, root: root} do
body = get(conn, root <> "/dists/stable/Release").resp_body
refute body =~ ~r/^MD5Sum:$/m
refute body =~ ~r/^SHA1:$/m
end
test "lists the uncompressed index as well as the compressed one", %{
conn: conn,
root: root
} do
body = get(conn, root <> "/dists/stable/Release").resp_body
assert body =~ "main/binary-amd64/Packages\n"
assert body =~ "main/binary-amd64/Packages.xz\n"
end
test "its checksums match what the server actually serves", %{conn: conn, root: root} do
release = get(conn, root <> "/dists/stable/Release").resp_body
listed =
Regex.scan(~r/^ ([0-9a-f]{64}) +(\d+) +(\S+)$/m, release)
|> Map.new(fn [_, digest, size, path] -> {path, {digest, String.to_integer(size)}} end)
assert map_size(listed) > 0
for {path, {digest, size}} <- listed do
served = get(build_conn(), root <> "/dists/stable/" <> path)
assert served.status == 200, "#{path} is listed in Release but not served"
assert byte_size(served.resp_body) == size, "#{path} size disagrees with Release"
assert Base.encode16(:crypto.hash(:sha256, served.resp_body), case: :lower) == digest,
"#{path} checksum disagrees with Release"
end
end
end
describe "InRelease and Release.gpg" do
setup %{conn: conn, root: root, token: token} do
upload(conn, root, token, control_fields())
:ok
end
test "InRelease is clearsigned and contains the Release fields", %{conn: conn, root: root} do
body = get(conn, root <> "/dists/stable/InRelease").resp_body
assert body =~ "-----BEGIN PGP SIGNED MESSAGE-----"
assert body =~ "-----BEGIN PGP SIGNATURE-----"
assert body =~ "Suite: stable"
end
test "Release.gpg verifies against the published key — this is apt's check", %{
conn: conn,
root: root
} do
key = get(conn, root <> "/key.asc").resp_body
release = get(build_conn(), root <> "/dists/stable/Release").resp_body
signature = get(build_conn(), root <> "/dists/stable/Release.gpg").resp_body
assert signature =~ "-----BEGIN PGP SIGNATURE-----"
assert {:ok, true} = Native.verify_detached(key, release, signature)
end
test "and fails against a tampered Release", %{conn: conn, root: root} do
key = get(conn, root <> "/key.asc").resp_body
release = get(build_conn(), root <> "/dists/stable/Release").resp_body
signature = get(build_conn(), root <> "/dists/stable/Release.gpg").resp_body
tampered = String.replace(release, "Suite: stable", "Suite: evil")
assert {:error, _} = Native.verify_detached(key, tampered, signature)
end
end
describe "Packages" do
test "carries what a stanza requires, Description-md5 included", %{
conn: conn,
repo: repo,
root: root,
token: token
} do
upload(conn, root, token, control_fields())
body = get(build_conn(), root <> "/dists/stable/main/binary-amd64/Packages").resp_body
assert body =~ ~r/^Package: widget$/m
assert body =~ ~r/^Version: 1\.0\.0-1$/m
assert body =~ ~r/^Architecture: amd64$/m
# The pool path is qualified by the source repository so that a
# namespace index aggregating several can't collide.
assert body =~
"Filename: pool/main/#{repo.name}/w/widget/widget_1.0.0-1_amd64.deb\n"
assert body =~ ~r/^Size: \d+$/m
assert body =~ ~r/^SHA256: [0-9a-f]{64}$/m
# Mandatory as of APT 2.7.7.
assert body =~ ~r/^Description-md5: [0-9a-f]{32}$/m
end
test "a lib package gets its own two-character pool shard", %{
conn: conn,
repo: repo,
root: root,
token: token
} do
upload(conn, root, token, control_fields(Package: "libwidget"))
body = get(build_conn(), root <> "/dists/stable/main/binary-amd64/Packages").resp_body
assert body =~
"Filename: pool/main/#{repo.name}/libw/libwidget/libwidget_1.0.0-1_amd64.deb"
end
test "arch:all appears in every architecture's index", %{
conn: conn,
root: root,
token: token
} do
upload(conn, root, token, control_fields(Package: "widget", Architecture: "amd64"))
upload(build_conn(), root, token, control_fields(Package: "docs", Architecture: "all"))
amd64 = get(build_conn(), root <> "/dists/stable/main/binary-amd64/Packages").resp_body
all = get(build_conn(), root <> "/dists/stable/main/binary-all/Packages").resp_body
assert amd64 =~ "Package: widget"
assert amd64 =~ "Package: docs"
# …and in binary-all on its own, which is where Debian puts it.
assert all =~ "Package: docs"
refute all =~ "Package: widget"
end
test "the .xz index decompresses to the plain one", %{conn: conn, root: root, token: token} do
upload(conn, root, token, control_fields())
plain = get(build_conn(), root <> "/dists/stable/main/binary-amd64/Packages").resp_body
compressed = get(build_conn(), root <> "/dists/stable/main/binary-amd64/Packages.xz")
assert get_resp_header(compressed, "content-type") == ["application/x-xz"]
assert {:ok, ^plain} = Native.xz_decompress(compressed.resp_body)
end
test "a package uploaded to another suite stays out of this one", %{
conn: conn,
root: root,
token: token
} do
upload(conn, root, token, control_fields(), query: "?dist=unstable")
assert get(build_conn(), root <> "/dists/stable/main/binary-amd64/Packages").resp_body == ""
assert get(build_conn(), root <> "/dists/unstable/main/binary-amd64/Packages").resp_body =~
"Package: widget"
end
end
describe "pool" do
test "serves the .deb bytes at exactly the path the index names", %{
conn: conn,
repo: repo,
root: root,
token: token
} do
body = deb(control_fields())
conn
|> put_req_header("authorization", token)
|> put_req_header("content-type", "application/octet-stream")
|> post(root <> "/upload", body)
# Take the path out of the index rather than reconstructing it —
# a Filename apt can't fetch is the failure that matters.
index = get(build_conn(), root <> "/dists/stable/main/binary-amd64/Packages").resp_body
[_, filename] = Regex.run(~r/^Filename: (\S+)$/m, index)
assert filename == "pool/main/#{repo.name}/w/widget/widget_1.0.0-1_amd64.deb"
served = get(build_conn(), root <> "/" <> filename)
assert served.status == 200
assert served.resp_body == body
end
test "404 for a .deb nobody uploaded", %{conn: conn, repo: repo, root: root} do
assert get(conn, root <> "/pool/main/#{repo.name}/g/ghost/ghost_1.0_amd64.deb").status ==
404
end
test "404 for a path that names another repository's pool", %{
conn: conn,
root: root,
token: token
} do
upload(conn, root, token, control_fields())
assert get(build_conn(), root <> "/pool/main/elsewhere/w/widget/widget_1.0.0-1_amd64.deb").status ==
404
end
end
describe "upload" do
test "reports where the package landed", %{conn: conn, root: root, token: token} do
conn =
upload(conn, root, token, control_fields(), query: "?dist=bookworm&component=contrib")
assert conn.status == 200
assert %{
"package" => "widget",
"version" => "1.0.0-1",
"architecture" => "amd64",
"dist" => "bookworm",
"component" => "contrib"
} = Jason.decode!(conn.resp_body)
end
test "rejects something that isn't a .deb", %{conn: conn, root: root, token: token} do
conn =
conn
|> put_req_header("authorization", token)
|> put_req_header("content-type", "application/octet-stream")
|> post(root <> "/upload", "not a deb")
assert conn.status == 400
end
test "an anonymous upload is refused", %{conn: conn, root: root} do
assert upload(conn, root, nil, control_fields()).status == 401
end
test "409 on re-uploading the same file with different bytes", %{
conn: conn,
root: root,
token: token
} do
upload(conn, root, token, control_fields())
conn = upload(build_conn(), root, token, control_fields(Section: "admin"))
assert conn.status == 409
end
end
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
14.3 KiB · text
History
202c083