Package registries phase 2: Hex

4869a7f · Gabriel Morell · 2026-09-11 14:25

25 files +1280 -2
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/packages.ex
+62 −0
@@ -18,6 +18,8 @@ defmodule GitGud.Packages do
18 18 alias GitGud.Packages.PackageFile
19 19 alias GitGud.Packages.PackageVersion
20 20 alias GitGud.Packages.RegistryToken
21 + alias GitGud.Packages.SigningKey
22 + alias GitGud.Packages.Token
21 23 alias GitGud.Repo
22 24 alias GitGud.Repositories.Repository
23 25 alias GitGud.Storage
@@ -362,4 +364,64 @@ defmodule GitGud.Packages do
362 364 defp storage_key(repo_id, format, sha256) do
363 365 Enum.join([repo_id, format, binary_part(sha256, 0, 2), sha256], "/")
364 366 end
367 +
368 + # ── per-repository signing keys ──────────────────────────────────────
369 +
370 + @doc """
371 + The repository's keypair for `format`, generating one on first use.
372 +
373 + Returns `{:ok, %{public_pem: binary, private_pem: binary}}`. Hex and
374 + apt both need a key before they can serve anything at all, so
375 + lazy generation is what keeps "publish something" from needing a
376 + separate provisioning step.
377 +
378 + Generation races are resolved by the unique index: a loser re-reads
379 + the winner's row rather than clobbering it, because a public key that
380 + changed underneath a consumer is worse than a wasted keygen.
381 + """
382 + def signing_key(%Repository{id: rid} = repo, format) do
383 + case Repo.get_by(SigningKey, repository_id: rid, format: format) do
384 + %SigningKey{} = key -> unwrap_key(key)
385 + nil -> generate_signing_key(repo, format)
386 + end
387 + end
388 +
389 + defp generate_signing_key(%Repository{id: rid} = repo, format) do
390 + {private_pem, public_pem} = Token.generate_keypair()
391 + {:ok, wrapped} = GitGud.Secrets.encrypt(private_pem)
392 +
393 + %SigningKey{repository_id: rid}
394 + |> SigningKey.changeset(%{
395 + format: format,
396 + public_key: public_pem,
397 + private_key_ciphertext: wrapped.ciphertext,
398 + private_key_iv: wrapped.iv,
399 + private_key_tag: wrapped.tag,
400 + key_id: wrapped.key_id
401 + })
402 + |> Repo.insert()
403 + |> case do
404 + {:ok, _key} ->
405 + {:ok, %{public_pem: public_pem, private_pem: private_pem}}
406 +
407 + {:error, _changeset} ->
408 + # Another request generated one between our read and our write.
409 + case Repo.get_by(SigningKey, repository_id: rid, format: format) do
410 + %SigningKey{} = key -> unwrap_key(key)
411 + nil -> signing_key(repo, format)
412 + end
413 + end
414 + end
415 +
416 + defp unwrap_key(%SigningKey{} = key) do
417 + case GitGud.Secrets.decrypt(%{
418 + ciphertext: key.private_key_ciphertext,
419 + iv: key.private_key_iv,
420 + tag: key.private_key_tag,
421 + key_id: key.key_id
422 + }) do
423 + {:ok, private_pem} -> {:ok, %{public_pem: key.public_key, private_pem: private_pem}}
424 + {:error, _} = err -> err
425 + end
426 + end
365 427 end
modified lib/git_gud/packages/format.ex
+4 −1
@@ -45,7 +45,10 @@ defmodule GitGud.Packages.Format do
45 45 # distribution API itself, so `oci` exists only as a `format` value
46 46 # the packages UI can list alongside everything else. Each remaining
47 47 # format appears here as its adapter lands.
48 − @default_adapters %{"pypi" => GitGud.Packages.Formats.Pypi}
48 + @default_adapters %{
49 + "pypi" => GitGud.Packages.Formats.Pypi,
50 + "hex" => GitGud.Packages.Formats.Hex
51 + }
49 52
50 53 @typedoc """
51 54 Request context handed to an adapter.
added lib/git_gud/packages/formats/hex.ex
+293 −0
@@ -0,0 +1,293 @@
1 +defmodule GitGud.Packages.Formats.Hex do
2 + @moduledoc """
3 + A Hex registry v2 repository, per git repository.
4 +
5 + mix hex.repo add <name> https://<host>/pkg/hex/<owner>/<repo> \\
6 + --public-key=<(curl -s https://<host>/pkg/hex/<owner>/<repo>/public_key)
7 +
8 + # then, in deps:
9 + {:internal_thing, "~> 1.0", repo: "<name>"}
10 +
11 + Paths below the repository root:
12 +
13 + /names every package, signed
14 + /versions every version of every package, signed
15 + /packages/<NAME> one package's releases, signed
16 + /tarballs/<NAME>-<VERSION>.tar the published tarball
17 + /public_key the key that verifies the above
18 + /publish POST a tarball here
19 +
20 + ## Building the registry rather than rebuilding it
21 +
22 + The documented way to self-host is `mix hex.registry build`, which
23 + regenerates the whole directory from a folder of tarballs. That makes
24 + publishing a rebuild, and a rebuild is O(everything) — so this
25 + encodes the protobufs on request instead, straight out of Postgres.
26 + Publishing becomes an insert.
27 +
28 + ## The signature
29 +
30 + Every resource is a `Signed{payload, signature}`, and the signature
31 + is an RSA signature over the **SHA-512 digest of the payload**, both
32 + unencoded. Then the encoded `Signed` is gzipped — and gzipped as
33 + *content*, not as a transfer encoding: `mix` gunzips the body itself,
34 + so a `Content-Encoding: gzip` header would have the HTTP client
35 + decompress it first and leave `mix` with something it can't read.
36 +
37 + ## Repository name
38 +
39 + `mix` refuses a payload whose `repository` field doesn't match the
40 + name the repo was added under, so the name isn't cosmetic. It is
41 + derived here (`<owner>_<repo>`), and the packages page prints the
42 + exact `mix hex.repo add` line to paste.
43 + """
44 +
45 + @behaviour GitGud.Packages.Format
46 +
47 + alias GitGud.Packages
48 + alias GitGud.Packages.Formats.Hex.Pb
49 + alias GitGud.Packages.Formats.Hex.Tarball
50 + alias GitGud.Packages.Package
51 + alias GitGud.Packages.PackageFile
52 + alias GitGud.Repositories.Repository
53 + alias GitGud.Repositories.Storage
54 +
55 + @format "hex"
56 + @signed "application/octet-stream"
57 +
58 + @doc """
59 + The name `mix hex.repo add` must be given for this repository.
60 +
61 + Underscore-joined rather than hyphen-joined because repository names
62 + routinely contain hyphens, and `acme-web-utils` shouldn't be
63 + ambiguous about where the owner stops.
64 + """
65 + def repo_name(%Repository{} = repo) do
66 + repo = GitGud.Repo.preload(repo, [:owner, :organization])
67 + "#{Storage.repo_handle(repo)}_#{repo.name}"
68 + end
69 +
70 + @impl true
71 + def content_type(_filename), do: "application/octet-stream"
72 +
73 + # ── reads ───────────────────────────────────────────────────────────
74 +
75 + @impl true
76 + def index(repo, ctx)
77 +
78 + def index(repo, %{path: ["public_key"]}) do
79 + with {:ok, %{public_pem: pem}} <- Packages.signing_key(repo, @format) do
80 + {:ok, "text/plain; charset=utf-8", pem}
81 + end
82 + end
83 +
84 + def index(repo, %{path: ["names"]}) do
85 + packages =
86 + repo
87 + |> Packages.list_packages(@format)
88 + |> Enum.map(&%Pb.Names.Package{name: &1.name})
89 +
90 + sign(repo, Pb.Names, %Pb.Names{repository: repo_name(repo), packages: packages})
91 + end
92 +
93 + def index(repo, %{path: ["versions"]}) do
94 + packages =
95 + repo
96 + |> Packages.list_packages(@format)
97 + |> Enum.map(fn package ->
98 + releases = sorted_versions(package)
99 +
100 + %Pb.Versions.Package{
101 + name: package.name,
102 + versions: Enum.map(releases, & &1.version),
103 + # Indexes into `versions`, not version strings — the spec
104 + # keeps the list positional.
105 + retired: retired_indexes(releases)
106 + }
107 + end)
108 +
109 + sign(repo, Pb.Versions, %Pb.Versions{repository: repo_name(repo), packages: packages})
110 + end
111 +
112 + def index(repo, %{path: ["packages", name]}) do
113 + case Packages.get_package(repo, @format, name) do
114 + nil ->
115 + {:error, :not_found}
116 +
117 + %Package{} = package ->
118 + releases = package |> sorted_versions() |> Enum.map(&release(&1))
119 +
120 + sign(repo, Pb.Package, %Pb.Package{
121 + repository: repo_name(repo),
122 + name: package.name,
123 + releases: releases
124 + })
125 + end
126 + end
127 +
128 + def index(repo, %{path: ["tarballs", filename]}) do
129 + case find_tarball(repo, filename) do
130 + %PackageFile{} = file -> {:blob, file}
131 + nil -> {:error, :not_found}
132 + end
133 + end
134 +
135 + def index(_repo, _ctx), do: {:error, :not_found}
136 +
137 + defp find_tarball(repo, filename) do
138 + repo
139 + |> Packages.list_packages(@format)
140 + |> Enum.find_value(fn package -> Packages.get_package_file(package, filename) end)
141 + end
142 +
143 + # Hex resolves against this list directly, so ordering is the
144 + # registry's job. Unparseable versions sort last by string rather
145 + # than crashing the whole index.
146 + defp sorted_versions(%Package{} = package) do
147 + package
148 + |> Packages.list_versions()
149 + |> Enum.sort(fn a, b ->
150 + case {Version.parse(a.version), Version.parse(b.version)} do
151 + {{:ok, va}, {:ok, vb}} -> Version.compare(va, vb) != :gt
152 + {{:ok, _}, :error} -> true
153 + {:error, {:ok, _}} -> false
154 + _ -> a.version <= b.version
155 + end
156 + end)
157 + end
158 +
159 + defp retired_indexes(releases) do
160 + releases
161 + |> Enum.with_index()
162 + |> Enum.filter(fn {release, _index} -> release.yanked end)
163 + |> Enum.map(fn {_release, index} -> index end)
164 + end
165 +
166 + defp release(version) do
167 + metadata = version.metadata || %{}
168 +
169 + %Pb.Release{
170 + version: version.version,
171 + inner_checksum: decode_checksum(metadata["inner_checksum"]),
172 + outer_checksum: decode_checksum(metadata["outer_checksum"]),
173 + dependencies: Enum.map(metadata["dependencies"] || [], &dependency/1),
174 + retired: retirement(version)
175 + }
176 + end
177 +
178 + # Checksums travel as hex in the database and as raw bytes on the
179 + # wire.
180 + defp decode_checksum(nil), do: <<>>
181 +
182 + defp decode_checksum(hex) when is_binary(hex) do
183 + case Base.decode16(hex, case: :mixed) do
184 + {:ok, raw} -> raw
185 + :error -> <<>>
186 + end
187 + end
188 +
189 + defp dependency(dep) when is_map(dep) do
190 + %Pb.Dependency{
191 + package: dep["package"] || dep["name"] || "",
192 + requirement: dep["requirement"] || "",
193 + optional: dep["optional"] || false,
194 + app: dep["app"],
195 + repository: dep["repository"]
196 + }
197 + end
198 +
199 + # Retirement is Hex's yank: the release stays resolvable and `mix`
200 + # warns. We only ever record RETIRED_OTHER — the finer reasons are a
201 + # publisher's claim, and nothing in our publish path establishes one.
202 + defp retirement(%{yanked: true} = version),
203 + do: %Pb.RetirementStatus{reason: :RETIRED_OTHER, message: version.yanked_reason}
204 +
205 + defp retirement(_version), do: nil
206 +
207 + # ── signing ─────────────────────────────────────────────────────────
208 +
209 + defp sign(repo, module, message) do
210 + with {:ok, %{private_pem: pem}} <- Packages.signing_key(repo, @format) do
211 + payload = module.encode(message)
212 +
213 + signed =
214 + Pb.Signed.encode(%Pb.Signed{
215 + payload: payload,
216 + signature: rsa_sign(payload, pem)
217 + })
218 +
219 + {:ok, @signed, :zlib.gzip(signed)}
220 + end
221 + end
222 +
223 + # "An RSA signature over the SHA-512 digest of the payload, both
224 + # unencoded" — which is exactly what :public_key.sign/3 computes,
225 + # digest included.
226 + defp rsa_sign(payload, private_pem) do
227 + [entry] = :public_key.pem_decode(private_pem)
228 + :public_key.sign(payload, :sha512, :public_key.pem_entry_decode(entry))
229 + end
230 +
231 + @doc """
232 + Verify a signed resource against a public key PEM. Not used to serve
233 + anything — it exists so the tests can check what a client will check,
234 + rather than checking that we called our own signer.
235 + """
236 + def verify(signed_gzip, public_pem) do
237 + %Pb.Signed{payload: payload, signature: signature} =
238 + signed_gzip |> :zlib.gunzip() |> Pb.Signed.decode()
239 +
240 + [entry] = :public_key.pem_decode(public_pem)
241 +
242 + {payload,
243 + :public_key.verify(payload, :sha512, signature, :public_key.pem_entry_decode(entry))}
244 + end
245 +
246 + # ── publishing ──────────────────────────────────────────────────────
247 +
248 + @impl true
249 + def store(repo, ctx, body)
250 +
251 + def store(repo, %{method: "POST", path: path} = ctx, body)
252 + when is_binary(body) and (path == ["publish"] or path == []) do
253 + with {:ok, meta} <- Tarball.parse(body) do
254 + filename = "#{meta.name}-#{meta.version}.tar"
255 +
256 + attrs = %{
257 + filename: filename,
258 + body: body,
259 + content_type: content_type(filename),
260 + metadata: %{"inner_checksum" => meta.inner_checksum},
261 + version_metadata: %{
262 + "inner_checksum" => meta.inner_checksum,
263 + "outer_checksum" => Base.encode16(:crypto.hash(:sha256, body), case: :lower),
264 + "dependencies" => meta.dependencies,
265 + "app" => meta.app,
266 + "description" => meta.description
267 + },
268 + uploader: uploader(ctx)
269 + }
270 +
271 + case Packages.publish_file(repo, @format, meta.name, meta.version, attrs) do
272 + {:ok, _package, _version, _file} ->
273 + {:ok, "application/json",
274 + Jason.encode_to_iodata!(%{"name" => meta.name, "version" => meta.version})}
275 +
276 + {:error, :file_exists} ->
277 + {:error, :file_exists}
278 +
279 + {:error, reason} ->
280 + {:error, {400, inspect(reason)}}
281 + end
282 + end
283 + end
284 +
285 + def store(_repo, %{method: "POST"}, _body),
286 + do: {:error, {400, "POST the raw Hex tarball to /publish"}}
287 +
288 + def store(_repo, %{method: method}, _body),
289 + do: {:error, {405, "#{method} is not supported by the Hex registry"}}
290 +
291 + defp uploader(%{principal: {:user, user}}), do: user
292 + defp uploader(_ctx), do: nil
293 +end
added lib/git_gud/packages/formats/hex/registry.pb.ex
+155 −0
@@ -0,0 +1,155 @@
1 +# Hex registry v2 messages — see priv/proto/hex/registry.proto.
2 +#
3 +# Written by hand rather than generated: this repo's toolchain has no
4 +# protoc, and these definitions are transcribed from the ones compiled
5 +# into hex_core, which is what `mix` actually decodes with. Keep the
6 +# field numbers and `required:` flags in step with the .proto.
7 +
8 +defmodule GitGud.Packages.Formats.Hex.Pb.Signed do
9 + @moduledoc false
10 +
11 + use Protobuf, full_name: "hex.Signed", syntax: :proto2
12 +
13 + field :payload, 1, required: true, type: :bytes
14 + field :signature, 2, optional: true, type: :bytes
15 +end
16 +
17 +defmodule GitGud.Packages.Formats.Hex.Pb.Timestamp do
18 + @moduledoc false
19 +
20 + use Protobuf, full_name: "hex.Timestamp", syntax: :proto2
21 +
22 + field :seconds, 1, required: true, type: :int64
23 + field :nanos, 2, required: true, type: :int32
24 +end
25 +
26 +defmodule GitGud.Packages.Formats.Hex.Pb.RetirementReason do
27 + @moduledoc false
28 +
29 + use Protobuf, enum: true, full_name: "hex.RetirementReason", syntax: :proto2
30 +
31 + field :RETIRED_OTHER, 0
32 + field :RETIRED_INVALID, 1
33 + field :RETIRED_SECURITY, 2
34 + field :RETIRED_DEPRECATED, 3
35 + field :RETIRED_RENAMED, 4
36 +end
37 +
38 +defmodule GitGud.Packages.Formats.Hex.Pb.AdvisorySeverity do
39 + @moduledoc false
40 +
41 + use Protobuf, enum: true, full_name: "hex.AdvisorySeverity", syntax: :proto2
42 +
43 + field :SEVERITY_NONE, 0
44 + field :SEVERITY_LOW, 1
45 + field :SEVERITY_MEDIUM, 2
46 + field :SEVERITY_HIGH, 3
47 + field :SEVERITY_CRITICAL, 4
48 +end
49 +
50 +defmodule GitGud.Packages.Formats.Hex.Pb.Names.Package do
51 + @moduledoc false
52 +
53 + use Protobuf, full_name: "hex.Names.Package", syntax: :proto2
54 +
55 + field :name, 1, required: true, type: :string
56 + field :updated_at, 3, optional: true, type: GitGud.Packages.Formats.Hex.Pb.Timestamp
57 +end
58 +
59 +defmodule GitGud.Packages.Formats.Hex.Pb.Names do
60 + @moduledoc false
61 +
62 + use Protobuf, full_name: "hex.Names", syntax: :proto2
63 +
64 + field :packages, 1, repeated: true, type: GitGud.Packages.Formats.Hex.Pb.Names.Package
65 + field :repository, 2, required: true, type: :string
66 +end
67 +
68 +defmodule GitGud.Packages.Formats.Hex.Pb.Versions.Package do
69 + @moduledoc false
70 +
71 + use Protobuf, full_name: "hex.Versions.Package", syntax: :proto2
72 +
73 + field :name, 1, required: true, type: :string
74 + field :versions, 2, repeated: true, type: :string
75 + field :retired, 3, repeated: true, type: :int32, packed: true
76 + field :with_advisories, 5, repeated: true, type: :int32, packed: true
77 +end
78 +
79 +defmodule GitGud.Packages.Formats.Hex.Pb.Versions do
80 + @moduledoc false
81 +
82 + use Protobuf, full_name: "hex.Versions", syntax: :proto2
83 +
84 + field :packages, 1, repeated: true, type: GitGud.Packages.Formats.Hex.Pb.Versions.Package
85 + field :repository, 2, required: true, type: :string
86 +end
87 +
88 +defmodule GitGud.Packages.Formats.Hex.Pb.RetirementStatus do
89 + @moduledoc false
90 +
91 + use Protobuf, full_name: "hex.RetirementStatus", syntax: :proto2
92 +
93 + field :reason, 1,
94 + required: true,
95 + type: GitGud.Packages.Formats.Hex.Pb.RetirementReason,
96 + enum: true
97 +
98 + field :message, 2, optional: true, type: :string
99 +end
100 +
101 +defmodule GitGud.Packages.Formats.Hex.Pb.Dependency do
102 + @moduledoc false
103 +
104 + use Protobuf, full_name: "hex.Dependency", syntax: :proto2
105 +
106 + field :package, 1, required: true, type: :string
107 + field :requirement, 2, required: true, type: :string
108 + field :optional, 3, optional: true, type: :bool
109 + field :app, 4, optional: true, type: :string
110 + field :repository, 5, optional: true, type: :string
111 +end
112 +
113 +defmodule GitGud.Packages.Formats.Hex.Pb.SecurityAdvisory do
114 + @moduledoc false
115 +
116 + use Protobuf, full_name: "hex.SecurityAdvisory", syntax: :proto2
117 +
118 + field :id, 1, required: true, type: :string
119 + field :summary, 2, required: true, type: :string
120 + field :html_url, 3, required: true, type: :string
121 +
122 + field :severity, 4,
123 + optional: true,
124 + type: GitGud.Packages.Formats.Hex.Pb.AdvisorySeverity,
125 + enum: true
126 +
127 + field :cvss_score, 5, optional: true, type: :float
128 + field :api_url, 6, required: true, type: :string
129 + field :aliases, 7, repeated: true, type: :string
130 +end
131 +
132 +defmodule GitGud.Packages.Formats.Hex.Pb.Release do
133 + @moduledoc false
134 +
135 + use Protobuf, full_name: "hex.Release", syntax: :proto2
136 +
137 + field :version, 1, required: true, type: :string
138 + field :inner_checksum, 2, required: true, type: :bytes
139 + field :dependencies, 3, repeated: true, type: GitGud.Packages.Formats.Hex.Pb.Dependency
140 + field :retired, 4, optional: true, type: GitGud.Packages.Formats.Hex.Pb.RetirementStatus
141 + field :outer_checksum, 5, optional: true, type: :bytes
142 + field :advisory_indexes, 6, repeated: true, type: :uint32
143 + field :published_at, 7, optional: true, type: GitGud.Packages.Formats.Hex.Pb.Timestamp
144 +end
145 +
146 +defmodule GitGud.Packages.Formats.Hex.Pb.Package do
147 + @moduledoc false
148 +
149 + use Protobuf, full_name: "hex.Package", syntax: :proto2
150 +
151 + field :releases, 1, repeated: true, type: GitGud.Packages.Formats.Hex.Pb.Release
152 + field :name, 2, required: true, type: :string
153 + field :repository, 3, required: true, type: :string
154 + field :advisories, 4, repeated: true, type: GitGud.Packages.Formats.Hex.Pb.SecurityAdvisory
155 +end
added lib/git_gud/packages/formats/hex/tarball.ex
+178 −0
@@ -0,0 +1,178 @@
1 +defmodule GitGud.Packages.Formats.Hex.Tarball do
2 + @moduledoc """
3 + Reads the metadata out of a published Hex tarball.
4 +
5 + The format is an uncompressed `.tar` holding four members:
6 +
7 + VERSION the tarball format version ("3")
8 + CHECKSUM the inner checksum, hex, uppercase
9 + metadata.config Erlang terms — name, version, requirements, …
10 + contents.tar.gz the package itself
11 +
12 + Only the first three are read. The inner checksum is taken from
13 + `CHECKSUM` rather than recomputed: that file is what it is for, and
14 + recomputing means reimplementing the exact concatenation Hex hashes,
15 + which is a thing to get subtly wrong rather than a thing to verify.
16 + The outer checksum is just SHA-256 of the whole tarball, which the
17 + substrate already computes.
18 +
19 + `metadata.config` is Erlang term syntax. It is parsed with the
20 + scanner and `:erl_parse.parse_term/1` — never `:erl_eval` — so an
21 + uploaded file is data and only data.
22 + """
23 +
24 + @type meta :: %{
25 + name: String.t(),
26 + version: String.t(),
27 + app: String.t() | nil,
28 + description: String.t() | nil,
29 + inner_checksum: String.t(),
30 + dependencies: [map()]
31 + }
32 +
33 + @doc """
34 + Parse a `.tar`. Returns `{:ok, meta}` or `{:error, {status, message}}`
35 + shaped for the registry controller.
36 + """
37 + @spec parse(binary()) :: {:ok, meta()} | {:error, {integer(), String.t()}}
38 + def parse(tarball) when is_binary(tarball) do
39 + with {:ok, members} <- extract(tarball),
40 + {:ok, config} <- member(members, "metadata.config"),
41 + {:ok, checksum} <- member(members, "CHECKSUM"),
42 + {:ok, terms} <- consult(config),
43 + {:ok, name} <- fetch(terms, "name"),
44 + {:ok, version} <- fetch(terms, "version") do
45 + {:ok,
46 + %{
47 + name: name,
48 + version: version,
49 + app: optional(terms, "app"),
50 + description: optional(terms, "description"),
51 + inner_checksum: checksum |> String.trim() |> String.downcase(),
52 + dependencies: requirements(terms)
53 + }}
54 + end
55 + end
56 +
57 + defp extract(tarball) do
58 + case :erl_tar.extract({:binary, tarball}, [:memory]) do
59 + {:ok, members} ->
60 + {:ok, Map.new(members, fn {name, content} -> {to_string(name), content} end)}
61 +
62 + {:error, reason} ->
63 + {:error, {400, "not a readable tar archive: #{inspect(reason)}"}}
64 + end
65 + end
66 +
67 + defp member(members, name) do
68 + case Map.fetch(members, name) do
69 + {:ok, content} -> {:ok, content}
70 + :error -> {:error, {400, "the tarball has no #{name} member"}}
71 + end
72 + end
73 +
74 + # ── Erlang term config ──────────────────────────────────────────────
75 +
76 + # `:file.consult/1` over a binary, without the temp file: scan the
77 + # whole thing once, cut it at each `.`, and parse each form as a
78 + # term. `:erl_parse.parse_term/1` cannot evaluate anything, which is
79 + # the point — this input arrived over HTTP.
80 + defp consult(binary) do
81 + case :erl_scan.string(String.to_charlist(binary)) do
82 + {:ok, tokens, _} ->
83 + tokens
84 + |> forms()
85 + |> Enum.reduce_while({:ok, []}, fn form, {:ok, acc} ->
86 + case :erl_parse.parse_term(form) do
87 + {:ok, term} -> {:cont, {:ok, [term | acc]}}
88 + {:error, _} -> {:halt, {:error, {400, "metadata.config is not readable"}}}
89 + end
90 + end)
91 + |> case do
92 + {:ok, terms} -> {:ok, Enum.reverse(terms)}
93 + err -> err
94 + end
95 +
96 + {:error, _, _} ->
97 + {:error, {400, "metadata.config is not readable"}}
98 + end
99 + end
100 +
101 + defp forms(tokens) do
102 + tokens
103 + |> Enum.chunk_while(
104 + [],
105 + fn
106 + {:dot, _} = dot, acc -> {:cont, Enum.reverse([dot | acc]), []}
107 + token, acc -> {:cont, [token | acc]}
108 + end,
109 + fn
110 + [] -> {:cont, []}
111 + acc -> {:cont, Enum.reverse(acc), []}
112 + end
113 + )
114 + |> Enum.reject(&(&1 == []))
115 + end
116 +
117 + defp fetch(terms, key) do
118 + case optional(terms, key) do
119 + nil -> {:error, {400, "metadata.config has no #{key}"}}
120 + value -> {:ok, value}
121 + end
122 + end
123 +
124 + defp optional(terms, key) do
125 + Enum.find_value(terms, fn
126 + {k, value} -> if to_string(k) == key, do: to_string(value)
127 + _ -> nil
128 + end)
129 + end
130 +
131 + # Requirements are a list of proplists in current Hex, and a proplist
132 + # keyed by package name in older tarballs. Both still turn up in a
133 + # `deps.get` cache, so both are read.
134 + defp requirements(terms) do
135 + case Enum.find_value(terms, fn
136 + {k, value} -> if to_string(k) == "requirements", do: value
137 + _ -> nil
138 + end) do
139 + nil -> []
140 + list when is_list(list) -> Enum.map(list, &requirement/1)
141 + _ -> []
142 + end
143 + end
144 +
145 + # The legacy shape keys each proplist by the package name, and the
146 + # inner proplist has no "name" of its own — so the key is the only
147 + # place the dependency's name appears.
148 + defp requirement({name, props}) when is_list(props) do
149 + case requirement(props) do
150 + %{"package" => ""} = parsed -> %{parsed | "package" => to_string(name)}
151 + parsed -> parsed
152 + end
153 + end
154 +
155 + defp requirement(props) when is_list(props) do
156 + fields =
157 + Enum.reduce(props, %{}, fn
158 + {k, v}, acc -> Map.put(acc, to_string(k), normalize(v))
159 + _, acc -> acc
160 + end)
161 +
162 + %{
163 + "package" => fields["name"] || fields["package"] || "",
164 + "requirement" => fields["requirement"] || "",
165 + "optional" => fields["optional"] == true,
166 + "app" => fields["app"],
167 + "repository" => fields["repository"]
168 + }
169 + end
170 +
171 + defp requirement(_), do: %{"package" => "", "requirement" => "", "optional" => false}
172 +
173 + defp normalize(value) when is_binary(value), do: value
174 + defp normalize(value) when is_boolean(value), do: value
175 + defp normalize(value) when is_atom(value), do: to_string(value)
176 + defp normalize(value) when is_list(value), do: to_string(value)
177 + defp normalize(value), do: value
178 +end
added lib/git_gud/packages/signing_key.ex
+51 −0
@@ -0,0 +1,51 @@
1 +defmodule GitGud.Packages.SigningKey do
2 + @moduledoc """
3 + A repository's keypair for one registry format.
4 +
5 + The public half is served to clients verbatim — it is what a consumer
6 + pins with `mix hex.repo add --public-key` — and the private half is
7 + encrypted at rest with the `GitGud.Secrets` master key. Rows keep the
8 + `key_id` they were wrapped under so a master-key rotation can re-wrap
9 + them the same way secret rows are.
10 + """
11 + use Ecto.Schema
12 +
13 + import Ecto.Changeset
14 +
15 + alias GitGud.Repositories.Repository
16 +
17 + schema "package_signing_keys" do
18 + field :format, :string
19 + field :public_key, :string
20 + field :private_key_ciphertext, :binary, redact: true
21 + field :private_key_iv, :binary, redact: true
22 + field :private_key_tag, :binary, redact: true
23 + field :key_id, :string
24 +
25 + belongs_to :repository, Repository
26 +
27 + timestamps(type: :utc_datetime)
28 + end
29 +
30 + @doc false
31 + def changeset(key, attrs) do
32 + key
33 + |> cast(attrs, [
34 + :format,
35 + :public_key,
36 + :private_key_ciphertext,
37 + :private_key_iv,
38 + :private_key_tag,
39 + :key_id
40 + ])
41 + |> validate_required([
42 + :format,
43 + :public_key,
44 + :private_key_ciphertext,
45 + :private_key_iv,
46 + :private_key_tag,
47 + :key_id
48 + ])
49 + |> unique_constraint([:repository_id, :format])
50 + end
51 +end
modified lib/git_gud_web/live/repo_live/packages.ex
+16 −1
@@ -51,7 +51,10 @@ defmodule GitGudWeb.RepoLive.Packages do
51 51 # The OCI registry is a separate host (zot); everything else is
52 52 # served by this forge.
53 53 host: Application.get_env(:git_gud, :registry_host),
54 − root: registry_root()
54 + root: registry_root(),
55 + # The name `mix hex.repo add` must use; Hex checks it against
56 + # the registry payload.
57 + hex_repo: GitGud.Packages.Formats.Hex.repo_name(repo)
55 58 })
56 59 |> assign(:page_title, "Packages — #{owner}/#{name}")}
57 60 end
@@ -194,6 +197,14 @@ defmodule GitGudWeb.RepoLive.Packages do
194 197 defp configure_line("pypi", %{handle: handle, repo_name: name, root: root}),
195 198 do: "pip config set global.index-url #{root}/pkg/pypi/#{handle}/#{name}/simple/"
196 199
200 + # Hex verifies the registry signature against a pinned public key and
201 + # refuses a payload whose repository name doesn't match the one it
202 + # was added under — so both halves of this line are load-bearing.
203 + defp configure_line("hex", %{handle: handle, repo_name: name, root: root, hex_repo: hex_repo}) do
204 + url = "#{root}/pkg/hex/#{handle}/#{name}"
205 + "mix hex.repo add #{hex_repo} #{url} --public-key=<(curl -s #{url}/public_key)"
206 + end
207 +
197 208 defp configure_line(_format, _registry), do: nil
198 209
199 210 # Reconstruct the zot repository path the webhook deflated: a package
@@ -210,6 +221,10 @@ defmodule GitGudWeb.RepoLive.Packages do
210 221 "pip install --index-url #{root}/pkg/pypi/#{handle}/#{name}/simple/ #{pkg.name}==#{version}"
211 222 end
212 223
224 + defp install_line("hex", %{hex_repo: hex_repo}, pkg, version) do
225 + ~s[{:#{pkg.name}, "~> #{version}", repo: "#{hex_repo}"}]
226 + end
227 +
213 228 defp install_line(_format, _registry, _pkg, _version), do: nil
214 229
215 230 defp short_digest("sha256:" <> rest),
added priv/proto/hex/registry.proto
+104 −0
@@ -0,0 +1,104 @@
1 +// Hex registry v2 — https://github.com/hexpm/specifications/blob/main/registry-v2.md
2 +//
3 +// Transcribed from the message definitions compiled into hex_core
4 +// (mix_hex_pb_names, _versions, _package, _signed), which is the
5 +// authority `mix` itself decodes with. proto2: several fields are
6 +// `required`, and Hex's own encoder relies on that.
7 +//
8 +// There is no protoc in this repo's toolchain, so the Elixir modules
9 +// in lib/git_gud/packages/formats/hex/registry.pb.ex are written by
10 +// hand to match. This file is the source of truth for what they mean.
11 +
12 +syntax = "proto2";
13 +
14 +package hex;
15 +
16 +message Signed {
17 + required bytes payload = 1;
18 + optional bytes signature = 2;
19 +}
20 +
21 +message Timestamp {
22 + required int64 seconds = 1;
23 + required int32 nanos = 2;
24 +}
25 +
26 +message Names {
27 + repeated Package packages = 1;
28 + required string repository = 2;
29 +
30 + message Package {
31 + required string name = 1;
32 + optional Timestamp updated_at = 3;
33 + }
34 +}
35 +
36 +message Versions {
37 + repeated Package packages = 1;
38 + required string repository = 2;
39 +
40 + message Package {
41 + required string name = 1;
42 + repeated string versions = 2;
43 + repeated int32 retired = 3 [packed = true];
44 + repeated int32 with_advisories = 5 [packed = true];
45 + }
46 +}
47 +
48 +enum RetirementReason {
49 + RETIRED_OTHER = 0;
50 + RETIRED_INVALID = 1;
51 + RETIRED_SECURITY = 2;
52 + RETIRED_DEPRECATED = 3;
53 + RETIRED_RENAMED = 4;
54 +}
55 +
56 +enum AdvisorySeverity {
57 + SEVERITY_NONE = 0;
58 + SEVERITY_LOW = 1;
59 + SEVERITY_MEDIUM = 2;
60 + SEVERITY_HIGH = 3;
61 + SEVERITY_CRITICAL = 4;
62 +}
63 +
64 +message RetirementStatus {
65 + required RetirementReason reason = 1;
66 + optional string message = 2;
67 +}
68 +
69 +message Dependency {
70 + required string package = 1;
71 + required string requirement = 2;
72 + optional bool optional = 3;
73 + optional string app = 4;
74 + optional string repository = 5;
75 +}
76 +
77 +message SecurityAdvisory {
78 + required string id = 1;
79 + required string summary = 2;
80 + required string html_url = 3;
81 + optional AdvisorySeverity severity = 4;
82 + optional float cvss_score = 5;
83 + required string api_url = 6;
84 + repeated string aliases = 7;
85 +}
86 +
87 +message Release {
88 + required string version = 1;
89 + // SHA-256 of the *inner* tarball (contents.tar.gz).
90 + required bytes inner_checksum = 2;
91 + repeated Dependency dependencies = 3;
92 + optional RetirementStatus retired = 4;
93 + // SHA-256 of the whole published .tar.
94 + optional bytes outer_checksum = 5;
95 + repeated uint32 advisory_indexes = 6;
96 + optional Timestamp published_at = 7;
97 +}
98 +
99 +message Package {
100 + repeated Release releases = 1;
101 + required string name = 2;
102 + required string repository = 3;
103 + repeated SecurityAdvisory advisories = 4;
104 +}
added priv/repo/migrations/20260911010000_create_package_signing_keys.exs
+37 −0
@@ -0,0 +1,37 @@
1 +defmodule GitGud.Repo.Migrations.CreatePackageSigningKeys do
2 + use Ecto.Migration
3 +
4 + @moduledoc """
5 + Per-repository, per-format signing keys for the package registries.
6 +
7 + Hex requires every registry resource to be signed, and apt requires a
8 + signed `Release`. Both want a keypair whose public half a client
9 + pins once (`mix hex.repo add --public-key`, `/etc/apt/keyrings`) and
10 + whose private half never leaves the server — so the private key is
11 + encrypted at rest with the same AES-256-GCM master key
12 + `GitGud.Secrets` uses, and carries the same `key_id` so a rotation
13 + re-wrap reaches it too.
14 +
15 + Per repository rather than per forge: the public key is the thing a
16 + consumer pins, and one shared key would mean rotating it breaks every
17 + repository at once.
18 + """
19 +
20 + def change do
21 + create table(:package_signing_keys) do
22 + add :repository_id, references(:repositories, on_delete: :delete_all), null: false
23 + add :format, :string, null: false, size: 20
24 + # Public half, PEM, served verbatim to clients.
25 + add :public_key, :text, null: false
26 + # Private half, AES-256-GCM under the GitGud.Secrets master key.
27 + add :private_key_ciphertext, :binary, null: false
28 + add :private_key_iv, :binary, null: false
29 + add :private_key_tag, :binary, null: false
30 + add :key_id, :string, null: false
31 +
32 + timestamps(type: :utc_datetime)
33 + end
34 +
35 + create unique_index(:package_signing_keys, [:repository_id, :format])
36 + end
37 +end
added test/git_gud_web/hex_registry_test.exs
+380 −0
@@ -0,0 +1,380 @@
1 +defmodule GitGudWeb.HexRegistryTest do
2 + @moduledoc """
3 + Phase 2: the Hex registry v2 repository, over HTTP.
4 +
5 + The assertions that matter here are the ones `mix` makes: the
6 + payload decodes, the signature verifies against the published public
7 + key, and the `repository` field matches the name the repo was added
8 + under — `mix` rejects the registry outright if any of those is off.
9 + """
10 + use GitGudWeb.ConnCase, async: false
11 +
12 + alias GitGud.Packages
13 + alias GitGud.Packages.Formats.Hex, as: HexFormat
14 + alias GitGud.Packages.Formats.Hex.Pb
15 + alias GitGud.Packages.Formats.Hex.Tarball
16 + alias GitGud.Repositories.Storage
17 +
18 + import GitGud.ForgeFixtures
19 +
20 + setup do
21 + {user, repo} = repository_fixture(%{visibility: "public"})
22 + {:ok, _t, token} = Packages.create_registry_token(repo, "ci", ["pull", "push"])
23 +
24 + {:ok,
25 + user: user,
26 + repo: repo,
27 + token: token,
28 + root: "/pkg/hex/#{Storage.owner_handle(user)}/#{repo.name}"}
29 + end
30 +
31 + # A published Hex tarball: an uncompressed .tar of VERSION, CHECKSUM,
32 + # metadata.config and contents.tar.gz.
33 + defp hex_tarball(name, version, opts \\ []) do
34 + requirements = Keyword.get(opts, :requirements, [])
35 + contents = :zlib.gzip("the package source")
36 +
37 + config =
38 + [
39 + ~s({<<"name">>, <<"#{name}">>}.),
40 + ~s({<<"version">>, <<"#{version}">>}.),
41 + ~s({<<"app">>, <<"#{name}">>}.),
42 + ~s({<<"description">>, <<"a test package">>}.),
43 + requirements_term(requirements)
44 + ]
45 + |> Enum.join("\n")
46 +
47 + inner = :crypto.hash(:sha256, "3" <> config <> contents)
48 +
49 + members = [
50 + {~c"VERSION", "3"},
51 + {~c"CHECKSUM", Base.encode16(inner, case: :upper)},
52 + {~c"metadata.config", config},
53 + {~c"contents.tar.gz", contents}
54 + ]
55 +
56 + path = Path.join(System.tmp_dir!(), "hex-#{System.unique_integer([:positive])}.tar")
57 + :ok = :erl_tar.create(String.to_charlist(path), members, [])
58 + File.read!(path)
59 + end
60 +
61 + defp requirements_term([]), do: ~s({<<"requirements">>, []}.)
62 +
63 + defp requirements_term(reqs) do
64 + entries =
65 + Enum.map_join(reqs, ",", fn {pkg, req} ->
66 + ~s([{<<"name">>,<<"#{pkg}">>},{<<"app">>,<<"#{pkg}">>},) <>
67 + ~s({<<"optional">>,false},{<<"requirement">>,<<"#{req}">>},) <>
68 + ~s({<<"repository">>,<<"hexpm">>}])
69 + end)
70 +
71 + ~s({<<"requirements">>, [#{entries}]}.)
72 + end
73 +
74 + defp publish(conn, root, token, tarball) do
75 + conn
76 + |> put_req_header("authorization", token)
77 + |> put_req_header("content-type", "application/octet-stream")
78 + |> post(root <> "/publish", tarball)
79 + end
80 +
81 + defp public_key(conn, root) do
82 + get(conn, root <> "/public_key").resp_body
83 + end
84 +
85 + # Exactly what a client does: gunzip, decode Signed, verify the RSA
86 + # signature over the SHA-512 digest of the payload, then decode.
87 + defp fetch_signed(conn, root, path, module, message) do
88 + pem = public_key(build_conn(), root)
89 + body = get(conn, root <> path).resp_body
90 +
91 + {payload, verified?} = HexFormat.verify(body, pem)
92 + assert verified?, "#{path} did not verify against the published public key"
93 +
94 + {module.decode(payload), message}
95 + |> elem(0)
96 + end
97 +
98 + describe "/public_key" do
99 + test "serves a PEM the registry's own signatures verify against", %{conn: conn, root: root} do
100 + pem = public_key(conn, root)
101 +
102 + assert pem =~ "-----BEGIN PUBLIC KEY-----"
103 + assert [{:SubjectPublicKeyInfo, _, _}] = :public_key.pem_decode(pem)
104 + end
105 +
106 + test "is stable across requests — a rotating key breaks every consumer", %{
107 + conn: conn,
108 + root: root
109 + } do
110 + assert public_key(conn, root) == public_key(build_conn(), root)
111 + end
112 + end
113 +
114 + describe "/names" do
115 + test "lists published packages, signed", %{conn: conn, repo: repo, root: root, token: token} do
116 + publish(conn, root, token, hex_tarball("widget", "1.0.0"))
117 +
118 + names = fetch_signed(build_conn(), root, "/names", Pb.Names, Pb.Names)
119 +
120 + assert names.repository == HexFormat.repo_name(repo)
121 + assert Enum.map(names.packages, & &1.name) == ["widget"]
122 + end
123 +
124 + test "the repository field matches the name mix must add it under", %{
125 + conn: conn,
126 + repo: repo,
127 + root: root,
128 + token: token
129 + } do
130 + publish(conn, root, token, hex_tarball("widget", "1.0.0"))
131 +
132 + names = fetch_signed(build_conn(), root, "/names", Pb.Names, Pb.Names)
133 +
134 + # `mix` rejects the whole registry when these disagree.
135 + assert names.repository == HexFormat.repo_name(repo)
136 + assert names.repository =~ repo.name
137 + end
138 +
139 + test "a tampered payload fails verification", %{conn: conn, root: root, token: token} do
140 + publish(conn, root, token, hex_tarball("widget", "1.0.0"))
141 +
142 + pem = public_key(build_conn(), root)
143 +
144 + %Pb.Signed{} =
145 + signed =
146 + get(build_conn(), root <> "/names").resp_body |> :zlib.gunzip() |> Pb.Signed.decode()
147 +
148 + forged =
149 + %Pb.Signed{signed | payload: Pb.Names.encode(%Pb.Names{repository: "evil", packages: []})}
150 + |> Pb.Signed.encode()
151 + |> :zlib.gzip()
152 +
153 + assert {_payload, false} = HexFormat.verify(forged, pem)
154 + end
155 + end
156 +
157 + describe "/versions" do
158 + test "lists versions in order with retired entries by index", %{
159 + conn: conn,
160 + repo: repo,
161 + root: root,
162 + token: token
163 + } do
164 + for v <- ["1.0.0", "1.2.0", "1.10.0"] do
165 + publish(build_conn(), root, token, hex_tarball("widget", v))
166 + end
167 +
168 + pkg = Packages.get_package(repo, "hex", "widget")
169 + {:ok, _} = Packages.set_yanked(Packages.get_version(pkg, "1.2.0"), true, "bad build")
170 +
171 + versions = fetch_signed(conn, root, "/versions", Pb.Versions, Pb.Versions)
172 +
173 + assert [package] = versions.packages
174 + # Semver order, not lexicographic — 1.10.0 sorts after 1.2.0.
175 + assert package.versions == ["1.0.0", "1.2.0", "1.10.0"]
176 + # Positional: retirement points at an index in `versions`.
177 + assert package.retired == [1]
178 + end
179 + end
180 +
181 + describe "/packages/<name>" do
182 + test "carries checksums and dependencies", %{conn: conn, root: root, token: token} do
183 + tarball = hex_tarball("widget", "1.0.0", requirements: [{"jason", "~> 1.0"}])
184 + publish(conn, root, token, tarball)
185 +
186 + package = fetch_signed(build_conn(), root, "/packages/widget", Pb.Package, Pb.Package)
187 +
188 + assert [release] = package.releases
189 + assert release.version == "1.0.0"
190 +
191 + # Checksums go out as raw bytes, not hex.
192 + assert byte_size(release.inner_checksum) == 32
193 + assert release.outer_checksum == :crypto.hash(:sha256, tarball)
194 +
195 + assert [dep] = release.dependencies
196 + assert dep.package == "jason"
197 + assert dep.requirement == "~> 1.0"
198 + refute dep.optional
199 + end
200 +
201 + test "a yanked release is retired, not removed", %{
202 + conn: conn,
203 + repo: repo,
204 + root: root,
205 + token: token
206 + } do
207 + publish(conn, root, token, hex_tarball("widget", "1.0.0"))
208 + pkg = Packages.get_package(repo, "hex", "widget")
209 + {:ok, _} = Packages.set_yanked(Packages.get_version(pkg, "1.0.0"), true, "bad build")
210 +
211 + package = fetch_signed(build_conn(), root, "/packages/widget", Pb.Package, Pb.Package)
212 +
213 + assert [%Pb.Release{retired: retired}] = package.releases
214 + assert retired.reason == :RETIRED_OTHER
215 + assert retired.message == "bad build"
216 + end
217 +
218 + test "404 for an unpublished package", %{conn: conn, root: root} do
219 + assert get(conn, root <> "/packages/nothing").status == 404
220 + end
221 + end
222 +
223 + describe "/tarballs" do
224 + test "serves the published bytes back verbatim", %{conn: conn, root: root, token: token} do
225 + tarball = hex_tarball("widget", "1.0.0")
226 + publish(conn, root, token, tarball)
227 +
228 + conn = get(build_conn(), root <> "/tarballs/widget-1.0.0.tar")
229 +
230 + assert conn.status == 200
231 + assert conn.resp_body == tarball
232 + end
233 + end
234 +
235 + describe "publishing" do
236 + test "derives name, version and dependencies from the tarball", %{
237 + conn: conn,
238 + repo: repo,
239 + root: root,
240 + token: token
241 + } do
242 + conn =
243 + publish(
244 + conn,
245 + root,
246 + token,
247 + hex_tarball("widget", "1.0.0", requirements: [{"jason", "~> 1.0"}])
248 + )
249 +
250 + assert conn.status == 200
251 + assert %{"name" => "widget", "version" => "1.0.0"} = Jason.decode!(conn.resp_body)
252 +
253 + pkg = Packages.get_package(repo, "hex", "widget")
254 + version = Packages.get_version(pkg, "1.0.0")
255 +
256 + assert [%{"package" => "jason", "requirement" => "~> 1.0"}] =
257 + version.metadata["dependencies"]
258 + end
259 +
260 + test "rejects something that isn't a tar", %{conn: conn, root: root, token: token} do
261 + conn = publish(conn, root, token, "definitely not a tarball")
262 +
263 + assert conn.status == 400
264 + assert conn.resp_body =~ "tar"
265 + end
266 +
267 + test "rejects a tar with no metadata.config", %{conn: conn, root: root, token: token} do
268 + path = Path.join(System.tmp_dir!(), "empty-#{System.unique_integer([:positive])}.tar")
269 + :ok = :erl_tar.create(String.to_charlist(path), [{~c"VERSION", "3"}], [])
270 +
271 + conn = publish(conn, root, token, File.read!(path))
272 +
273 + assert conn.status == 400
274 + assert conn.resp_body =~ "metadata.config"
275 + end
276 +
277 + test "an anonymous publish is refused", %{conn: conn, root: root} do
278 + conn =
279 + conn
280 + |> put_req_header("content-type", "application/octet-stream")
281 + |> post(root <> "/publish", hex_tarball("widget", "1.0.0"))
282 +
283 + assert conn.status == 401
284 + end
285 +
286 + test "409 on republishing a version with different bytes", %{
287 + conn: conn,
288 + root: root,
289 + token: token
290 + } do
291 + publish(conn, root, token, hex_tarball("widget", "1.0.0"))
292 +
293 + conn =
294 + publish(
295 + build_conn(),
296 + root,
297 + token,
298 + hex_tarball("widget", "1.0.0", requirements: [{"jason", "~> 1.0"}])
299 + )
300 +
301 + assert conn.status == 409
302 + end
303 + end
304 +
305 + describe "Tarball.parse/1" do
306 + test "reads the inner checksum out of CHECKSUM rather than recomputing it" do
307 + tarball = hex_tarball("widget", "1.0.0")
308 +
309 + assert {:ok, meta} = Tarball.parse(tarball)
310 + assert meta.name == "widget"
311 + assert meta.version == "1.0.0"
312 + assert meta.app == "widget"
313 + assert meta.description == "a test package"
314 + assert String.match?(meta.inner_checksum, ~r/\A[0-9a-f]{64}\z/)
315 + end
316 +
317 + test "reads the older proplist-keyed requirements shape too" do
318 + contents = :zlib.gzip("source")
319 +
320 + config = """
321 + {<<"name">>, <<"legacy">>}.
322 + {<<"version">>, <<"0.1.0">>}.
323 + {<<"requirements">>, [{<<"jason">>, [{<<"requirement">>, <<"~> 1.0">>}, {<<"optional">>, false}]}]}.
324 + """
325 +
326 + inner = :crypto.hash(:sha256, "3" <> config <> contents)
327 +
328 + path = Path.join(System.tmp_dir!(), "legacy-#{System.unique_integer([:positive])}.tar")
329 +
330 + :ok =
331 + :erl_tar.create(
332 + String.to_charlist(path),
333 + [
334 + {~c"VERSION", "3"},
335 + {~c"CHECKSUM", Base.encode16(inner, case: :upper)},
336 + {~c"metadata.config", config},
337 + {~c"contents.tar.gz", contents}
338 + ],
339 + []
340 + )
341 +
342 + assert {:ok, meta} = Tarball.parse(File.read!(path))
343 + assert [%{"package" => "jason", "requirement" => "~> 1.0"}] = meta.dependencies
344 + end
345 +
346 + test "metadata.config is parsed as data — a function call is not evaluated" do
347 + contents = :zlib.gzip("source")
348 + config = "{<<\"name\">>, os:cmd(\"touch /tmp/pwned\")}.\n"
349 + inner = :crypto.hash(:sha256, "3" <> config <> contents)
350 +
351 + path = Path.join(System.tmp_dir!(), "evil-#{System.unique_integer([:positive])}.tar")
352 +
353 + :ok =
354 + :erl_tar.create(
355 + String.to_charlist(path),
356 + [
357 + {~c"VERSION", "3"},
358 + {~c"CHECKSUM", Base.encode16(inner, case: :upper)},
359 + {~c"metadata.config", config},
360 + {~c"contents.tar.gz", contents}
361 + ],
362 + []
363 + )
364 +
365 + assert {:error, {400, _}} = Tarball.parse(File.read!(path))
366 + end
367 + end
368 +
369 + describe "visibility" do
370 + test "a private registry challenges an anonymous client" do
371 + {user, private} = repository_fixture(%{visibility: "private"})
372 + root = "/pkg/hex/#{Storage.owner_handle(user)}/#{private.name}"
373 +
374 + conn = get(build_conn(), root <> "/names")
375 +
376 + assert conn.status == 401
377 + assert [_] = get_resp_header(conn, "www-authenticate")
378 + end
379 + end
380 +end

Parents: ed33862